diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 5d9c15a..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -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 - diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index ba3362f..b9ba5a0 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -3,6 +3,16 @@ name: "Build & Release pipeline" on: pull_request: + paths: + - '.github/workflows/build-and-release.yml' + - '.goreleaser.yml' + - 'Dockerfile.multiarches' + - 'Dockerfile.singlearch' + - 'Makefile' + - 'frontend/**' + - 'integration-tests/**' + - 'proto/**' + - 'service/**' workflow_dispatch: push: tags: @@ -11,31 +21,49 @@ on: - main - next - beta + paths: + - '.github/workflows/build-and-release.yml' + - '.goreleaser.yml' + - 'Dockerfile.multiarches' + - 'Dockerfile.singlearch' + - 'Makefile' + - 'frontend/**' + - 'integration-tests/**' + - 'proto/**' + - 'service/**' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up QEMU id: qemu - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: image: tonistiigi/binfmt:latest 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: + node-version: '22' cache: 'npm' cache-dependency-path: frontend/package-lock.json + - name: Setup node + if: github.event_name == 'pull_request' + uses: actions/setup-node@v6.4.0 + with: + node-version: '22' + - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: 'service/go.mod' cache: true @@ -45,13 +73,15 @@ jobs: run: go version - 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: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_KEY }} - 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: registry: ghcr.io username: ${{ github.actor }} @@ -74,7 +104,7 @@ jobs: run: cd integration-tests && make -w - name: Archive integration tests - uses: actions/upload-artifact@v4.3.1 + uses: actions/upload-artifact@v7 if: always() with: name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}" @@ -83,12 +113,17 @@ jobs: !integration-tests/node_modules - name: Install goreleaser + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: goreleaser/goreleaser-action@v6 with: 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 - 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 with: extra_plugins: | @@ -100,8 +135,8 @@ jobs: GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }} - name: Archive binaries + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: actions/upload-artifact@v4.3.1 with: name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}" path: dist/OliveTin*.* - diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b85a6ae..4e72e7f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,13 +15,19 @@ name: "CodeQL" on: push: paths: - - 'cmd/**' - - 'internal/**' - - 'webui.dev/**' + - '.github/workflows/codeql-analysis.yml' + - 'frontend/**' - 'integration-tests/**' - - 'OliveTin.proto' + - 'proto/**' + - 'service/**' branches: [main] pull_request: + paths: + - '.github/workflows/codeql-analysis.yml' + - 'frontend/**' + - 'integration-tests/**' + - 'proto/**' + - 'service/**' branches: [main] schedule: - cron: '25 10 * * 5' @@ -51,6 +57,12 @@ jobs: cache: true cache-dependency-path: 'service/go.mod' + - name: Setup Node + if: matrix.language == 'javascript' + uses: actions/setup-node@v4 + with: + node-version: '22' + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index f7ed70d..2ae1607 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -4,11 +4,11 @@ name: "Codestyle checks" on: push: paths: - - 'cmd/**' - - 'internal/**' - - 'webui.dev/**' + - '.github/workflows/codestyle.yml' + - 'frontend/**' - 'integration-tests/**' - - 'OliveTin.proto' + - 'proto/**' + - 'service/**' jobs: @@ -31,5 +31,10 @@ jobs: - name: service run: make -wC service codestyle + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: frontend run: make -wC frontend codestyle diff --git a/.github/workflows/devskim.yml b/.github/workflows/devskim.yml index e9a68f4..ed4bbb9 100644 --- a/.github/workflows/devskim.yml +++ b/.github/workflows/devskim.yml @@ -7,8 +7,28 @@ name: DevSkim on: push: + paths: + - '.github/workflows/devskim.yml' + - '.goreleaser.yml' + - 'Dockerfile.multiarches' + - 'Dockerfile.singlearch' + - 'Makefile' + - 'frontend/**' + - 'integration-tests/**' + - 'proto/**' + - 'service/**' branches: [ "main" ] pull_request: + paths: + - '.github/workflows/devskim.yml' + - '.goreleaser.yml' + - 'Dockerfile.multiarches' + - 'Dockerfile.singlearch' + - 'Makefile' + - 'frontend/**' + - 'integration-tests/**' + - 'proto/**' + - 'service/**' branches: [ "main" ] schedule: - cron: '34 21 * * 2' diff --git a/.github/workflows/docs-antora.yml b/.github/workflows/docs-antora.yml new file mode 100644 index 0000000..52a7fbc --- /dev/null +++ b/.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 diff --git a/.gitignore b/.gitignore index aa5cc25..48e8d33 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ frontend/dist/ frontend/node_modules custom-frontend integration-tests/screenshots/ +integration-tests/flakey-test-runs.log +integration-tests/flakey-test-runs.jsonl .vscode/ webui/ server.log @@ -19,4 +21,7 @@ OliveTin integration-tests/configs/authRequireGuestsToLogin/sessions.yaml webui webui.dev -sessions.yaml \ No newline at end of file +sessions.yaml +docs/build/ +build/ +.cursor diff --git a/.goreleaser.yml b/.goreleaser.yml index 2e2ffad..a6ba475 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -76,8 +76,9 @@ archives: - README.md - src: Dockerfile.singlearch dst: Dockerfile + - examples/backupScript.sh - webui - - ./var/ + - var name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}" wrap_in_directory: true format_overrides: @@ -103,6 +104,7 @@ dockers_v2: - var/entities/ - config.yaml - var/helper-actions/ + - examples/backupScript.sh labels: org.opencontainers.image.revision: "{{ .FullCommit }}" org.opencontainers.image.version: "{{ .Tag }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4efad37..dda308e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,19 @@ repos: - id: end-of-file-fixer - id: check-yaml - 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 - repo: https://github.com/compilerla/conventional-pre-commit @@ -34,9 +47,23 @@ repos: pass_filenames: false always_run: true - - id: it - name: it - entry: make service-codestyle frontend-codestyle + - 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 + name: integration-tests + entry: make it language: system pass_filenames: false always_run: true diff --git a/AGENTS.md b/AGENTS.md index 7ff9f84..7f49368 100644 --- a/AGENTS.md +++ b/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) - **Integration tests**: `integration-tests/` - **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 - Run the server (dev): - From repo root: `go run ./service` - Unit tests (Go): - 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): - Single test: `cd integration-tests && npx --yes mocha test/general.mjs` - 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. - Match existing formatting; avoid unrelated reformatting. - 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) 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 - Review the contributing guidelines at `CONTRIBUTING.adoc`. - 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 `[#]` segment only when there is no linked issue. ### Troubleshooting - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL. - Executor panics: check for nil `Binding/Action` and add guards in step functions. - Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI. - - diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc index e0f0672..40ac7da 100644 --- a/CONTRIBUTING.adoc +++ b/CONTRIBUTING.adoc @@ -58,6 +58,7 @@ make 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). +* 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 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. diff --git a/Dockerfile.multiarches b/Dockerfile.multiarches index 21be473..f84f731 100644 --- a/Dockerfile.multiarches +++ b/Dockerfile.multiarches @@ -1,17 +1,17 @@ # 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. # For custom/local single-arch builds, use Dockerfile.singlearch instead. 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 && \ 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 -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.title OliveTin @@ -42,6 +42,8 @@ EXPOSE 1337/tcp COPY config.yaml /config COPY var/entities/* /config/entities/ +COPY examples/backupScript.sh /opt/backupScript.sh +RUN chmod 755 /opt/backupScript.sh VOLUME /config ARG TARGETPLATFORM diff --git a/Dockerfile.singlearch b/Dockerfile.singlearch index 1a9ae56..e432f87 100644 --- a/Dockerfile.singlearch +++ b/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 && \ 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 -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.title OliveTin @@ -35,6 +35,8 @@ EXPOSE 1337/tcp COPY config.yaml /config COPY var/entities/* /config/entities/ +COPY examples/backupScript.sh /opt/backupScript.sh +RUN chmod 755 /opt/backupScript.sh VOLUME /config COPY OliveTin /usr/bin/OliveTin diff --git a/README.md b/README.md index 3bc9214..a1c8a9d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![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) +[![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) @@ -19,6 +20,8 @@ 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 **Safely** give access to commands, for less technical people; diff --git a/SECURITY.md b/SECURITY.md index f0a4010..78480cb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,12 +2,59 @@ ## Supported Versions -Currently, only the `main` branch is "supported". +The following branches are currently being supported with security updates: | 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 -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. diff --git a/config.yaml b/config.yaml index dd238ef..c50b2df 100644 --- a/config.yaml +++ b/config.yaml @@ -6,14 +6,39 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337 # Choose from INFO (default), WARN and DEBUG -# Docs: https://docs.olivetin.app/advanced_configuration/logs.html +# Docs: https://docs.olivetin.app/advanced_configuration/logs.html logLevel: "INFO" # Actions are commands that are executed by OliveTin, and normally show up as # buttons on the WebUI. # -# Docs: https://docs.olivetin.app/action_execution/create_your_first.html +# Docs: https://docs.olivetin.app/action_buttons/create_your_first.html actions: + # Lots of people use OliveTin to build web interfaces for their electronics + # projects. It's best to install OliveTin as a native package (eg, .deb), and + # then you can use either a python script or the `gpio` command. + - title: Toggle GPIO light + shell: gpioset gpiochip1 9=1 || true # The "|| true" is to ignore errors the demo environment doesn't have GPIO access. + icon: light + + # Lots of people also use OliveTin to monitor their servers, like checking + # disk space, or checking logs. `onclick: execution-dialog` shows output. + - title: Check disk space + icon: disk + shell: df -h / + onclick: execution-dialog + + # This uses `onclick: execution-dialog` to show a dialog with more + # information about the command that was run. + - title: Check shell history + shell: cat ~/.bash_history + icon: logs + onclick: execution-dialog + + # Every action can still be run on demand from the web UI or API. The keys + # below are optional *additional* triggers (see each action and + # https://docs.olivetin.app/action_execution/ ). + # # This is the most simple action, it just runs the command and flashes the # button to indicate status. # @@ -22,47 +47,40 @@ actions: - title: Ping the Internet shell: ping -c 3 1.1.1.1 icon: ping - popupOnStart: execution-dialog-stdout-only + onclick: execution-dialog + # https://docs.olivetin.app/action_execution/onstartup.html + execOnStartup: true - # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just - # the command output. - - title: Check disk space - icon: disk - shell: df -h /media - popupOnStart: execution-dialog-stdout-only - - # 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. - - title: date - shell: date - id: date - timeout: 6 - icon: clock - popupOnStart: execution-button + - title: Sync Disks + shell: sync + id: syncdisks + icon: disk + onclick: execution-button maxRate: - limit: 3 duration: 1m # You are not limited to operating system commands, and of course you can run - # your own scripts. Here `maxConcurrent` stops the script running multiple - # times in parallel. There is also a timeout that will kill the command if it - # runs for too long. + # your own scripts. The backup-jobs action group limits how many backup-related + # actions can run at once; extra requests are queued instead of blocked. + # There is also a timeout that will kill the command if it runs for too long. - title: Run backup script shell: /opt/backupScript.sh shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '" - maxConcurrent: 1 + groups: [ backup-jobs ] timeout: 10 icon: backup - popupOnStart: execution-dialog + onclick: execution-dialog + # https://docs.olivetin.app/action_execution/oncalendar.html + execOnCalendarFile: examples/demo-olivetin-calendar.yaml + + - title: Verify backup archive + shell: sleep 3 && echo "Backup archive verified" + groups: [ backup-jobs ] + timeout: 30 + icon: backup + onclick: execution-dialog # When you want to prompt users for input, that is when you should use # `arguments` - this presents a popup dialog and asks for argument values. @@ -73,7 +91,12 @@ actions: shell: ping {{ host }} -c {{ count }} icon: ping timeout: 100 - popupOnStart: execution-dialog-stdout-only + onclick: history + # https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks + # with header X-OliveTin-Demo: ping-host (path and payload rules are documented). + execOnWebhook: + - matchHeaders: + X-OliveTin-Demo: ping-host arguments: - name: host title: Host @@ -95,7 +118,7 @@ actions: # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html - title: Restart Docker Container icon: restart - shell: docker restart {{ .CurrentEntity }} + shell: docker restart {{ container }} arguments: - name: container title: Container name @@ -110,7 +133,8 @@ actions: # Docs: https://docs.olivetin.app/args/input_confirmation.html - title: Delete old backups icon: ashtonished - shell: rm -rf /opt/oldBackups/ + justification: true + shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5 arguments: - type: html title: Description @@ -124,7 +148,7 @@ actions: # # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html - title: Get OliveTin Theme - exec: + exec: - "olivetin-get-theme" - "{{ themeGitRepo }}" - "{{ themeFolderName }}" @@ -148,7 +172,11 @@ actions: - title: "Setup easy SSH" icon: ssh shell: olivetin-setup-easy-ssh - popupOnStart: execution-dialog + onclick: execution-dialog + # Second webhook example: POST /webhooks?demo=setup-ssh + execOnWebhook: + - matchQuery: + demo: setup-ssh # Here's how to use SSH with the "easy" config, to restart a service on # another server. @@ -161,13 +189,6 @@ actions: timeout: 1 shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart' - # Lots of people use OliveTin to build web interfaces for their electronics - # projects. It's best to install OliveTin as a native package (eg, .deb), and - # then you can use either a python script or the `gpio` command. - - title: Toggle GPIO light - shell: gpioset gpiochip1 9=1 - icon: light - # There are several built-in shortcuts for the `icon` option, but you # can also just specify any HTML, this includes any unicode character, # or a link to a custom icon. @@ -215,6 +236,10 @@ actions: - title: Ping All Servers shell: "echo 'Ping all servers'" 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 }} icon: box @@ -228,6 +253,15 @@ actions: entity: container triggers: ["Update container entity file"] + - title: Long running action + shell: sleep 300 + timeout: 300 + icon: logs + onclick: execution-dialog + groups: [ con2queue10 ] + execOnCron: + - "@hourly" + # Lastly, you can hide actions from the web UI, this is useful for creating # background helpers that execute only on startup or a cron, for updating # entity files. @@ -269,6 +303,17 @@ entities: - file: entities/containers.json name: container +# Action groups share a concurrency limit across multiple actions. When the +# limit is reached, additional requests are queued and run in order. +# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups +actionGroups: + backup-jobs: + maxConcurrent: 1 + icon: backup + con2queue10: + maxConcurrent: 2 + queueSize: 10 + # Dashboards are a way of taking actions from the default "actions" view, and # organizing them into groups - either into folders, or fieldsets. # @@ -341,7 +386,7 @@ dashboards: # Security - Authentication -# This setting effectively enables or disables guests. +# This setting effectively enables or disables guests. # If set to "true", then users will have to login to do anything. authRequireGuestsToLogin: false @@ -350,7 +395,7 @@ authRequireGuestsToLogin: false # and JWT authentication which are documented separately. # # Docs: https://docs.olivetin.app/security/local.html -# +# # How to get a hashed password: # Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password authLocalUsers: diff --git a/docs/antora.yml b/docs/antora.yml new file mode 100644 index 0000000..ec27f39 --- /dev/null +++ b/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 + + diff --git a/docs/modules/ROOT/check_chevron_links.py b/docs/modules/ROOT/check_chevron_links.py new file mode 100755 index 0000000..b7683f9 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/check_no_h1.py b/docs/modules/ROOT/check_no_h1.py new file mode 100755 index 0000000..04319b1 --- /dev/null +++ b/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) + diff --git a/docs/modules/ROOT/check_unnavigable.py b/docs/modules/ROOT/check_unnavigable.py new file mode 100755 index 0000000..826f550 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/examples/action_customization/icons/config.yaml b/docs/modules/ROOT/examples/action_customization/icons/config.yaml new file mode 100644 index 0000000..13028dc --- /dev/null +++ b/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: "😎" + + - title: Iconify Icon + icon: + + - title: HTML Image (jpg/png/gif/etc) icon + shell: echo "Hello!" + icon: '' diff --git a/docs/modules/ROOT/examples/k8s_configmap.yml b/docs/modules/ROOT/examples/k8s_configmap.yml new file mode 100644 index 0000000..15131cf --- /dev/null +++ b/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!' diff --git a/docs/modules/ROOT/examples/k8s_deployment.yml b/docs/modules/ROOT/examples/k8s_deployment.yml new file mode 100644 index 0000000..0e6756d --- /dev/null +++ b/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 + diff --git a/docs/modules/ROOT/examples/k8s_ingress.yml b/docs/modules/ROOT/examples/k8s_ingress.yml new file mode 100644 index 0000000..af03a40 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/examples/reverse-proxies/etc/npm-docker-compose.yml b/docs/modules/ROOT/examples/reverse-proxies/etc/npm-docker-compose.yml new file mode 100644 index 0000000..c49c4e7 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf b/docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf new file mode 100644 index 0000000..3023d7a --- /dev/null +++ b/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; + } +} + diff --git a/docs/modules/ROOT/examples/solutions/container-control-panel/config/config.yaml b/docs/modules/ROOT/examples/solutions/container-control-panel/config/config.yaml new file mode 100644 index 0000000..5e8ff4d --- /dev/null +++ b/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 }}

{{ container.State }} + - title: 'Start {{ container.Names }}' + - title: 'Stop {{ container.Names }}' diff --git a/docs/modules/ROOT/examples/solutions/container-control-panel/config/containers.json b/docs/modules/ROOT/examples/solutions/container-control-panel/config/containers.json new file mode 100644 index 0000000..fc6f592 --- /dev/null +++ b/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"} diff --git a/docs/modules/ROOT/examples/solutions/directory-actions/config.yaml b/docs/modules/ROOT/examples/solutions/directory-actions/config.yaml new file mode 100644 index 0000000..436d265 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/config.yaml b/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/config.yaml new file mode 100644 index 0000000..a84130b --- /dev/null +++ b/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/config.yaml @@ -0,0 +1,28 @@ +logLevel: "INFO" + +actions: + - title: Turn heating up + icon: '🔼' + shell: /opt/heating.sh up + + - title: Turn heating down + icon: '🔽' + 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: | + 🌡
{{ heating.temperature }} + + - title: Turn heating up + - title: Turn heating down diff --git a/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/heating.yaml b/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/heating.yaml new file mode 100644 index 0000000..5d8aebb --- /dev/null +++ b/docs/modules/ROOT/examples/solutions/heating-control-panel/configs/heating.yaml @@ -0,0 +1,2 @@ +- title: Main heater + temperature: 20 degrees diff --git a/docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml b/docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml new file mode 100644 index 0000000..66dda25 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/examples/solutions/primitive-password/password.js b/docs/modules/ROOT/examples/solutions/primitive-password/password.js new file mode 100644 index 0000000..64cef31 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/examples/solutions/systemd-control-panel/config/config.yaml b/docs/modules/ROOT/examples/solutions/systemd-control-panel/config/config.yaml new file mode 100644 index 0000000..6fe5749 --- /dev/null +++ b/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: + entity: systemd_unit + triggers: + - Update services file + + - title: Start {{ systemd_unit.unit }} + shell: systemctl start {{ systemd_unit.unit }} + 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 }} diff --git a/docs/modules/ROOT/examples/solutions/systemd-control-panel/config/systemd_units.json b/docs/modules/ROOT/examples/solutions/systemd-control-panel/config/systemd_units.json new file mode 100644 index 0000000..00a0022 --- /dev/null +++ b/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"} diff --git a/docs/modules/ROOT/examples/solutions/wol/config.yaml b/docs/modules/ROOT/examples/solutions/wol/config.yaml new file mode 100644 index 0000000..ab38c02 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/examples/solutions/wol/config_docker.yaml b/docs/modules/ROOT/examples/solutions/wol/config_docker.yaml new file mode 100644 index 0000000..fc37b89 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/.gitignore b/docs/modules/ROOT/images/.gitignore new file mode 100644 index 0000000..63e998e --- /dev/null +++ b/docs/modules/ROOT/images/.gitignore @@ -0,0 +1,2 @@ +**/custom-webui +**/__pycache__ diff --git a/docs/modules/ROOT/images/SCREENSHOTS.md b/docs/modules/ROOT/images/SCREENSHOTS.md new file mode 100644 index 0000000..a9ff4ca --- /dev/null +++ b/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/// +├── screenshots.ini # batch capture config for repo-helper +├── config.yaml # minimal OliveTin config for this screenshot only +├── setup_.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:://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// +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. `` 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///` 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:://[]`. +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 ``. +> +> - Put everything in `docs/modules/ROOT/images///`: `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. diff --git a/docs/modules/ROOT/images/action-button-iconify.png b/docs/modules/ROOT/images/action-button-iconify.png new file mode 100644 index 0000000..94239d8 Binary files /dev/null and b/docs/modules/ROOT/images/action-button-iconify.png differ diff --git a/docs/modules/ROOT/images/action-confirmation.png b/docs/modules/ROOT/images/action-confirmation.png new file mode 100644 index 0000000..d681c03 Binary files /dev/null and b/docs/modules/ROOT/images/action-confirmation.png differ diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore b/docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/Makefile b/docs/modules/ROOT/images/action_buttons/create_your_first/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/action_buttons/create_your_first/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml b/docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml new file mode 100644 index 0000000..de35b8e --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png b/docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png new file mode 100644 index 0000000..792d61f Binary files /dev/null and b/docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png differ diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini b/docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini new file mode 100644 index 0000000..fc2651a --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py b/docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py new file mode 100644 index 0000000..0275a1f --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/action_buttons/layout/.gitignore b/docs/modules/ROOT/images/action_buttons/layout/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/action_buttons/layout/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/action_buttons/layout/Makefile b/docs/modules/ROOT/images/action_buttons/layout/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/action_buttons/layout/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/action_buttons/layout/config.yaml b/docs/modules/ROOT/images/action_buttons/layout/config.yaml new file mode 100644 index 0000000..0b08123 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_buttons/layout/layout.png b/docs/modules/ROOT/images/action_buttons/layout/layout.png new file mode 100644 index 0000000..f6d7ef4 Binary files /dev/null and b/docs/modules/ROOT/images/action_buttons/layout/layout.png differ diff --git a/docs/modules/ROOT/images/action_buttons/layout/screenshots.ini b/docs/modules/ROOT/images/action_buttons/layout/screenshots.ini new file mode 100644 index 0000000..44c7f6b --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_buttons/layout/setup_layout.py b/docs/modules/ROOT/images/action_buttons/layout/setup_layout.py new file mode 100644 index 0000000..ee3a9b2 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore b/docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/Makefile b/docs/modules/ROOT/images/action_customization/execution-dialog/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/action_customization/execution-dialog/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml b/docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml new file mode 100644 index 0000000..9861a44 --- /dev/null +++ b/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" diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png b/docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png new file mode 100644 index 0000000..f2a5a41 Binary files /dev/null and b/docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png differ diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini b/docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini new file mode 100644 index 0000000..23c485a --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py b/docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py new file mode 100644 index 0000000..bef9c99 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore b/docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/Makefile b/docs/modules/ROOT/images/action_customization/timeout-logs/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/action_customization/timeout-logs/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml b/docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml new file mode 100644 index 0000000..99c6f02 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini b/docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini new file mode 100644 index 0000000..7334fc7 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py b/docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py new file mode 100644 index 0000000..93c79a6 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png b/docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png new file mode 100644 index 0000000..c4c5977 Binary files /dev/null and b/docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png differ diff --git a/docs/modules/ROOT/images/additionalNavigationLinks.png b/docs/modules/ROOT/images/additionalNavigationLinks.png new file mode 100644 index 0000000..012a928 Binary files /dev/null and b/docs/modules/ROOT/images/additionalNavigationLinks.png differ diff --git a/docs/modules/ROOT/images/arg-datetime.png b/docs/modules/ROOT/images/arg-datetime.png new file mode 100644 index 0000000..f25158a Binary files /dev/null and b/docs/modules/ROOT/images/arg-datetime.png differ diff --git a/docs/modules/ROOT/images/args-choices-entities.png b/docs/modules/ROOT/images/args-choices-entities.png new file mode 100644 index 0000000..77a7ea4 Binary files /dev/null and b/docs/modules/ROOT/images/args-choices-entities.png differ diff --git a/docs/modules/ROOT/images/args-choices-exec.png b/docs/modules/ROOT/images/args-choices-exec.png new file mode 100644 index 0000000..aeca1d5 Binary files /dev/null and b/docs/modules/ROOT/images/args-choices-exec.png differ diff --git a/docs/modules/ROOT/images/args-multiline-text.png b/docs/modules/ROOT/images/args-multiline-text.png new file mode 100644 index 0000000..02ac610 Binary files /dev/null and b/docs/modules/ROOT/images/args-multiline-text.png differ diff --git a/docs/modules/ROOT/images/args/input/.gitignore b/docs/modules/ROOT/images/args/input/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/args/input/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/args/input/Makefile b/docs/modules/ROOT/images/args/input/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/args/input/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/args/input/args1.png b/docs/modules/ROOT/images/args/input/args1.png new file mode 100644 index 0000000..5400651 Binary files /dev/null and b/docs/modules/ROOT/images/args/input/args1.png differ diff --git a/docs/modules/ROOT/images/args/input/args2.png b/docs/modules/ROOT/images/args/input/args2.png new file mode 100644 index 0000000..e101daf Binary files /dev/null and b/docs/modules/ROOT/images/args/input/args2.png differ diff --git a/docs/modules/ROOT/images/args/input/args3.png b/docs/modules/ROOT/images/args/input/args3.png new file mode 100644 index 0000000..203171a Binary files /dev/null and b/docs/modules/ROOT/images/args/input/args3.png differ diff --git a/docs/modules/ROOT/images/args/input/config.yaml b/docs/modules/ROOT/images/args/input/config.yaml new file mode 100644 index 0000000..dca7e30 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/args/input/screenshots.ini b/docs/modules/ROOT/images/args/input/screenshots.ini new file mode 100644 index 0000000..5006aaa --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/args/input/setup_args1.py b/docs/modules/ROOT/images/args/input/setup_args1.py new file mode 100644 index 0000000..89ebaa2 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/args/input/setup_args2.py b/docs/modules/ROOT/images/args/input/setup_args2.py new file mode 100644 index 0000000..bae1017 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/args/input/setup_args3.py b/docs/modules/ROOT/images/args/input/setup_args3.py new file mode 100644 index 0000000..9eda93e --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/args/suggestions/.gitignore b/docs/modules/ROOT/images/args/suggestions/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/args/suggestions/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/args/suggestions/Makefile b/docs/modules/ROOT/images/args/suggestions/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/args/suggestions/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png b/docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png new file mode 100644 index 0000000..809f221 Binary files /dev/null and b/docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png differ diff --git a/docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png b/docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png new file mode 100644 index 0000000..b0f7573 Binary files /dev/null and b/docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png differ diff --git a/docs/modules/ROOT/images/args/suggestions/config.yaml b/docs/modules/ROOT/images/args/suggestions/config.yaml new file mode 100644 index 0000000..a69961b --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/args/suggestions/screenshots.ini b/docs/modules/ROOT/images/args/suggestions/screenshots.ini new file mode 100644 index 0000000..8f97efc --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/args/suggestions/setup_chrome.py b/docs/modules/ROOT/images/args/suggestions/setup_chrome.py new file mode 100644 index 0000000..1dc1be6 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/args/suggestions/setup_firefox.py b/docs/modules/ROOT/images/args/suggestions/setup_firefox.py new file mode 100644 index 0000000..0edaf73 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/args4.png b/docs/modules/ROOT/images/args4.png new file mode 100644 index 0000000..33b893f Binary files /dev/null and b/docs/modules/ROOT/images/args4.png differ diff --git a/docs/modules/ROOT/images/authentik_login.png b/docs/modules/ROOT/images/authentik_login.png new file mode 100644 index 0000000..28fafd0 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_login.png differ diff --git a/docs/modules/ROOT/images/authentik_login2.png b/docs/modules/ROOT/images/authentik_login2.png new file mode 100644 index 0000000..df868e0 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_login2.png differ diff --git a/docs/modules/ROOT/images/authentik_login3.png b/docs/modules/ROOT/images/authentik_login3.png new file mode 100644 index 0000000..9a9f411 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_login3.png differ diff --git a/docs/modules/ROOT/images/authentik_new_app.png b/docs/modules/ROOT/images/authentik_new_app.png new file mode 100644 index 0000000..d6d0995 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_new_app.png differ diff --git a/docs/modules/ROOT/images/authentik_provider_config.png b/docs/modules/ROOT/images/authentik_provider_config.png new file mode 100644 index 0000000..625cfb2 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_provider_config.png differ diff --git a/docs/modules/ROOT/images/authentik_provider_secrets.png b/docs/modules/ROOT/images/authentik_provider_secrets.png new file mode 100644 index 0000000..a0901ab Binary files /dev/null and b/docs/modules/ROOT/images/authentik_provider_secrets.png differ diff --git a/docs/modules/ROOT/images/authentik_select_oauth2.png b/docs/modules/ROOT/images/authentik_select_oauth2.png new file mode 100644 index 0000000..92a6605 Binary files /dev/null and b/docs/modules/ROOT/images/authentik_select_oauth2.png differ diff --git a/docs/modules/ROOT/images/blocked.png b/docs/modules/ROOT/images/blocked.png new file mode 100644 index 0000000..ea0b3b2 Binary files /dev/null and b/docs/modules/ROOT/images/blocked.png differ diff --git a/docs/modules/ROOT/images/dashboard-display.png b/docs/modules/ROOT/images/dashboard-display.png new file mode 100644 index 0000000..2554d7a Binary files /dev/null and b/docs/modules/ROOT/images/dashboard-display.png differ diff --git a/docs/modules/ROOT/images/dashboard-heating-control-panel.png b/docs/modules/ROOT/images/dashboard-heating-control-panel.png new file mode 100644 index 0000000..2d09885 Binary files /dev/null and b/docs/modules/ROOT/images/dashboard-heating-control-panel.png differ diff --git a/docs/modules/ROOT/images/dashboards/intro/.gitignore b/docs/modules/ROOT/images/dashboards/intro/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/dashboards/intro/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/dashboards/intro/Makefile b/docs/modules/ROOT/images/dashboards/intro/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/dashboards/intro/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/dashboards/intro/config.yaml b/docs/modules/ROOT/images/dashboards/intro/config.yaml new file mode 100644 index 0000000..d2760e2 --- /dev/null +++ b/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: {{ server.name }} + IP Address: {{ server.ip }} + - title: '{{ server.name }} Wake on Lan' + - title: '{{ server.name }} Power Off' diff --git a/docs/modules/ROOT/images/dashboards/intro/preview.png b/docs/modules/ROOT/images/dashboards/intro/preview.png new file mode 100644 index 0000000..77e9158 Binary files /dev/null and b/docs/modules/ROOT/images/dashboards/intro/preview.png differ diff --git a/docs/modules/ROOT/images/dashboards/intro/screenshots.ini b/docs/modules/ROOT/images/dashboards/intro/screenshots.ini new file mode 100644 index 0000000..048b585 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/dashboards/intro/servers.yaml b/docs/modules/ROOT/images/dashboards/intro/servers.yaml new file mode 100644 index 0000000..30c306d --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/dashboards/intro/setup_preview.py b/docs/modules/ROOT/images/dashboards/intro/setup_preview.py new file mode 100644 index 0000000..d8a9e41 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/defaultUiHideNav.png b/docs/modules/ROOT/images/defaultUiHideNav.png new file mode 100644 index 0000000..07eaf35 Binary files /dev/null and b/docs/modules/ROOT/images/defaultUiHideNav.png differ diff --git a/docs/modules/ROOT/images/defaultUiWithNav.png b/docs/modules/ROOT/images/defaultUiWithNav.png new file mode 100644 index 0000000..16d1a49 Binary files /dev/null and b/docs/modules/ROOT/images/defaultUiWithNav.png differ diff --git a/docs/modules/ROOT/images/diagnostics.png b/docs/modules/ROOT/images/diagnostics.png new file mode 100644 index 0000000..4b216e7 Binary files /dev/null and b/docs/modules/ROOT/images/diagnostics.png differ diff --git a/docs/modules/ROOT/images/directory-actions-screenshot.png b/docs/modules/ROOT/images/directory-actions-screenshot.png new file mode 100644 index 0000000..3b80c49 Binary files /dev/null and b/docs/modules/ROOT/images/directory-actions-screenshot.png differ diff --git a/docs/modules/ROOT/images/exampleIcons.png b/docs/modules/ROOT/images/exampleIcons.png new file mode 100644 index 0000000..92ba1c3 Binary files /dev/null and b/docs/modules/ROOT/images/exampleIcons.png differ diff --git a/docs/modules/ROOT/images/executionButtons.png b/docs/modules/ROOT/images/executionButtons.png new file mode 100644 index 0000000..7a6f1d7 Binary files /dev/null and b/docs/modules/ROOT/images/executionButtons.png differ diff --git a/docs/modules/ROOT/images/fieldset.png b/docs/modules/ROOT/images/fieldset.png new file mode 100644 index 0000000..cd9c0a5 Binary files /dev/null and b/docs/modules/ROOT/images/fieldset.png differ diff --git a/docs/modules/ROOT/images/flashyButton.png b/docs/modules/ROOT/images/flashyButton.png new file mode 100644 index 0000000..fb03e28 Binary files /dev/null and b/docs/modules/ROOT/images/flashyButton.png differ diff --git a/docs/modules/ROOT/images/folders.png b/docs/modules/ROOT/images/folders.png new file mode 100644 index 0000000..7a36ff9 Binary files /dev/null and b/docs/modules/ROOT/images/folders.png differ diff --git a/docs/modules/ROOT/images/gitops.png b/docs/modules/ROOT/images/gitops.png new file mode 100644 index 0000000..4288de6 Binary files /dev/null and b/docs/modules/ROOT/images/gitops.png differ diff --git a/docs/modules/ROOT/images/hacs-custom-repo.png b/docs/modules/ROOT/images/hacs-custom-repo.png new file mode 100644 index 0000000..a5e3dde Binary files /dev/null and b/docs/modules/ROOT/images/hacs-custom-repo.png differ diff --git a/docs/modules/ROOT/images/hacs-download.png b/docs/modules/ROOT/images/hacs-download.png new file mode 100644 index 0000000..159515a Binary files /dev/null and b/docs/modules/ROOT/images/hacs-download.png differ diff --git a/docs/modules/ROOT/images/hacs-dropdown.png b/docs/modules/ROOT/images/hacs-dropdown.png new file mode 100644 index 0000000..ddec470 Binary files /dev/null and b/docs/modules/ROOT/images/hacs-dropdown.png differ diff --git a/docs/modules/ROOT/images/hacs-search.png b/docs/modules/ROOT/images/hacs-search.png new file mode 100644 index 0000000..3fcafd8 Binary files /dev/null and b/docs/modules/ROOT/images/hacs-search.png differ diff --git a/docs/modules/ROOT/images/hass-add-integration.png b/docs/modules/ROOT/images/hass-add-integration.png new file mode 100644 index 0000000..46d5913 Binary files /dev/null and b/docs/modules/ROOT/images/hass-add-integration.png differ diff --git a/docs/modules/ROOT/images/hass-buttons.png b/docs/modules/ROOT/images/hass-buttons.png new file mode 100644 index 0000000..dad896b Binary files /dev/null and b/docs/modules/ROOT/images/hass-buttons.png differ diff --git a/docs/modules/ROOT/images/hass-configure-integration.png b/docs/modules/ROOT/images/hass-configure-integration.png new file mode 100644 index 0000000..5876874 Binary files /dev/null and b/docs/modules/ROOT/images/hass-configure-integration.png differ diff --git a/docs/modules/ROOT/images/hass-devices-and-services.png b/docs/modules/ROOT/images/hass-devices-and-services.png new file mode 100644 index 0000000..0b45924 Binary files /dev/null and b/docs/modules/ROOT/images/hass-devices-and-services.png differ diff --git a/docs/modules/ROOT/images/hassButtonSetup.png b/docs/modules/ROOT/images/hassButtonSetup.png new file mode 100644 index 0000000..b590c84 Binary files /dev/null and b/docs/modules/ROOT/images/hassButtonSetup.png differ diff --git a/docs/modules/ROOT/images/hassConfigYaml.png b/docs/modules/ROOT/images/hassConfigYaml.png new file mode 100644 index 0000000..b7bcd89 Binary files /dev/null and b/docs/modules/ROOT/images/hassConfigYaml.png differ diff --git a/docs/modules/ROOT/images/hassFileEditor.png b/docs/modules/ROOT/images/hassFileEditor.png new file mode 100644 index 0000000..9608753 Binary files /dev/null and b/docs/modules/ROOT/images/hassFileEditor.png differ diff --git a/docs/modules/ROOT/images/hassFileEditorConfig.png b/docs/modules/ROOT/images/hassFileEditorConfig.png new file mode 100644 index 0000000..eeb3ecc Binary files /dev/null and b/docs/modules/ROOT/images/hassFileEditorConfig.png differ diff --git a/docs/modules/ROOT/images/iconify.png b/docs/modules/ROOT/images/iconify.png new file mode 100644 index 0000000..a1c73d9 Binary files /dev/null and b/docs/modules/ROOT/images/iconify.png differ diff --git a/docs/modules/ROOT/images/icons/Discord.png b/docs/modules/ROOT/images/icons/Discord.png new file mode 100644 index 0000000..db47845 Binary files /dev/null and b/docs/modules/ROOT/images/icons/Discord.png differ diff --git a/docs/modules/ROOT/images/icons/GitHub.png b/docs/modules/ROOT/images/icons/GitHub.png new file mode 100644 index 0000000..329d361 Binary files /dev/null and b/docs/modules/ROOT/images/icons/GitHub.png differ diff --git a/docs/modules/ROOT/images/icons/OliveTinLogo.png b/docs/modules/ROOT/images/icons/OliveTinLogo.png new file mode 100644 index 0000000..cfe1027 Binary files /dev/null and b/docs/modules/ROOT/images/icons/OliveTinLogo.png differ diff --git a/docs/modules/ROOT/images/logs/views/.gitignore b/docs/modules/ROOT/images/logs/views/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/logs/views/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/logs/views/Makefile b/docs/modules/ROOT/images/logs/views/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/logs/views/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/logs/views/config.yaml b/docs/modules/ROOT/images/logs/views/config.yaml new file mode 100644 index 0000000..2a17b36 --- /dev/null +++ b/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 ] diff --git a/docs/modules/ROOT/images/logs/views/logsCalendar.png b/docs/modules/ROOT/images/logs/views/logsCalendar.png new file mode 100644 index 0000000..df5d12c Binary files /dev/null and b/docs/modules/ROOT/images/logs/views/logsCalendar.png differ diff --git a/docs/modules/ROOT/images/logs/views/logsList.png b/docs/modules/ROOT/images/logs/views/logsList.png new file mode 100644 index 0000000..9b3717b Binary files /dev/null and b/docs/modules/ROOT/images/logs/views/logsList.png differ diff --git a/docs/modules/ROOT/images/logs/views/logsQueue.png b/docs/modules/ROOT/images/logs/views/logsQueue.png new file mode 100644 index 0000000..c0a26a3 Binary files /dev/null and b/docs/modules/ROOT/images/logs/views/logsQueue.png differ diff --git a/docs/modules/ROOT/images/logs/views/screenshots.ini b/docs/modules/ROOT/images/logs/views/screenshots.ini new file mode 100644 index 0000000..2ab0e5c --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/logs/views/setup_logs_calendar.py b/docs/modules/ROOT/images/logs/views/setup_logs_calendar.py new file mode 100644 index 0000000..cdaa598 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/logs/views/setup_logs_list.py b/docs/modules/ROOT/images/logs/views/setup_logs_list.py new file mode 100644 index 0000000..a0fd09b --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/logs/views/setup_logs_queue.py b/docs/modules/ROOT/images/logs/views/setup_logs_queue.py new file mode 100644 index 0000000..34de4b4 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/maxRate.png b/docs/modules/ROOT/images/maxRate.png new file mode 100644 index 0000000..7721e2c Binary files /dev/null and b/docs/modules/ROOT/images/maxRate.png differ diff --git a/docs/modules/ROOT/images/mrGreenAction.png b/docs/modules/ROOT/images/mrGreenAction.png new file mode 100644 index 0000000..cd211a6 Binary files /dev/null and b/docs/modules/ROOT/images/mrGreenAction.png differ diff --git a/docs/modules/ROOT/images/mre.png b/docs/modules/ROOT/images/mre.png new file mode 100644 index 0000000..21eb2a6 Binary files /dev/null and b/docs/modules/ROOT/images/mre.png differ diff --git a/docs/modules/ROOT/images/mrgreen.gif b/docs/modules/ROOT/images/mrgreen.gif new file mode 100644 index 0000000..636a01f Binary files /dev/null and b/docs/modules/ROOT/images/mrgreen.gif differ diff --git a/docs/modules/ROOT/images/npm.png b/docs/modules/ROOT/images/npm.png new file mode 100644 index 0000000..5cde079 Binary files /dev/null and b/docs/modules/ROOT/images/npm.png differ diff --git a/docs/modules/ROOT/images/page-title.png b/docs/modules/ROOT/images/page-title.png new file mode 100644 index 0000000..12af3be Binary files /dev/null and b/docs/modules/ROOT/images/page-title.png differ diff --git a/docs/modules/ROOT/images/pocketid.png b/docs/modules/ROOT/images/pocketid.png new file mode 100644 index 0000000..e846e06 Binary files /dev/null and b/docs/modules/ROOT/images/pocketid.png differ diff --git a/docs/modules/ROOT/images/popupOutputOnly.png b/docs/modules/ROOT/images/popupOutputOnly.png new file mode 100644 index 0000000..69feb03 Binary files /dev/null and b/docs/modules/ROOT/images/popupOutputOnly.png differ diff --git a/docs/modules/ROOT/images/portDiagram.png b/docs/modules/ROOT/images/portDiagram.png new file mode 100644 index 0000000..ae1b527 Binary files /dev/null and b/docs/modules/ROOT/images/portDiagram.png differ diff --git a/docs/modules/ROOT/images/screenshots.mk b/docs/modules/ROOT/images/screenshots.mk new file mode 100644 index 0000000..53945e0 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/sidebar.png b/docs/modules/ROOT/images/sidebar.png new file mode 100644 index 0000000..bcae4ac Binary files /dev/null and b/docs/modules/ROOT/images/sidebar.png differ diff --git a/docs/modules/ROOT/images/snapshot-archive.png b/docs/modules/ROOT/images/snapshot-archive.png new file mode 100644 index 0000000..7716e71 Binary files /dev/null and b/docs/modules/ROOT/images/snapshot-archive.png differ diff --git a/docs/modules/ROOT/images/snapshot-download.png b/docs/modules/ROOT/images/snapshot-download.png new file mode 100644 index 0000000..44fb102 Binary files /dev/null and b/docs/modules/ROOT/images/snapshot-download.png differ diff --git a/docs/modules/ROOT/images/snapshots.png b/docs/modules/ROOT/images/snapshots.png new file mode 100644 index 0000000..6180126 Binary files /dev/null and b/docs/modules/ROOT/images/snapshots.png differ diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/.gitignore b/docs/modules/ROOT/images/solutions/container-control-panel/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/solutions/container-control-panel/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/Makefile b/docs/modules/ROOT/images/solutions/container-control-panel/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/solutions/container-control-panel/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/config.yaml b/docs/modules/ROOT/images/solutions/container-control-panel/config.yaml new file mode 100644 index 0000000..bf83696 --- /dev/null +++ b/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 }}

{{ container.State }} + - title: 'Start {{ container.Names }}' + - title: 'Stop {{ container.Names }}' diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/containers.json b/docs/modules/ROOT/images/solutions/container-control-panel/containers.json new file mode 100644 index 0000000..fc6f592 --- /dev/null +++ b/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"} diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/preview.png b/docs/modules/ROOT/images/solutions/container-control-panel/preview.png new file mode 100644 index 0000000..fc6f767 Binary files /dev/null and b/docs/modules/ROOT/images/solutions/container-control-panel/preview.png differ diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/screenshots.ini b/docs/modules/ROOT/images/solutions/container-control-panel/screenshots.ini new file mode 100644 index 0000000..9f57458 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/solutions/container-control-panel/setup_preview.py b/docs/modules/ROOT/images/solutions/container-control-panel/setup_preview.py new file mode 100644 index 0000000..20fb416 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/config.yaml b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/config.yaml new file mode 100644 index 0000000..b6e0191 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png new file mode 100644 index 0000000..8638739 Binary files /dev/null and b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png differ diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/screenshots.ini b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/screenshots.ini new file mode 100644 index 0000000..5a0dd5e --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/solutions/human-in-the-control-loop/setup_preview.py b/docs/modules/ROOT/images/solutions/human-in-the-control-loop/setup_preview.py new file mode 100644 index 0000000..565339c --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/config.yaml b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/config.yaml new file mode 100644 index 0000000..4d2db79 --- /dev/null +++ b/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: + 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: + shell: echo "deployment.apps/postgres restarted" + + - title: evacuate node + icon: + shell: echo "node/{{ NodeName }} cordoned and drained" + arguments: + - name: NodeName + type: ascii_identifier + choices: + - value: node1 + - value: node2 + - value: node3 diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png new file mode 100644 index 0000000..19d1bca Binary files /dev/null and b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png differ diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/screenshots.ini b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/screenshots.ini new file mode 100644 index 0000000..15d777a --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/setup_preview.py b/docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/setup_preview.py new file mode 100644 index 0000000..82f649b --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/.gitignore b/docs/modules/ROOT/images/solutions/systemd-control-panel/.gitignore new file mode 100644 index 0000000..431dbf1 --- /dev/null +++ b/docs/modules/ROOT/images/solutions/systemd-control-panel/.gitignore @@ -0,0 +1,2 @@ +custom-webui/ +__pycache__/ diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/Makefile b/docs/modules/ROOT/images/solutions/systemd-control-panel/Makefile new file mode 100644 index 0000000..720e66a --- /dev/null +++ b/docs/modules/ROOT/images/solutions/systemd-control-panel/Makefile @@ -0,0 +1,2 @@ +CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../../screenshots.mk diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/config.yaml b/docs/modules/ROOT/images/solutions/systemd-control-panel/config.yaml new file mode 100644 index 0000000..2c6e612 --- /dev/null +++ b/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: + entity: systemd_unit + + - title: Start {{ systemd_unit.unit }} + shell: echo "start {{ systemd_unit.unit }}" + 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 }} diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/preview.png b/docs/modules/ROOT/images/solutions/systemd-control-panel/preview.png new file mode 100644 index 0000000..09ee808 Binary files /dev/null and b/docs/modules/ROOT/images/solutions/systemd-control-panel/preview.png differ diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/screenshots.ini b/docs/modules/ROOT/images/solutions/systemd-control-panel/screenshots.ini new file mode 100644 index 0000000..f7669a3 --- /dev/null +++ b/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 diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/setup_preview.py b/docs/modules/ROOT/images/solutions/systemd-control-panel/setup_preview.py new file mode 100644 index 0000000..78af5a9 --- /dev/null +++ b/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) diff --git a/docs/modules/ROOT/images/solutions/systemd-control-panel/systemd_units.json b/docs/modules/ROOT/images/solutions/systemd-control-panel/systemd_units.json new file mode 100644 index 0000000..00a0022 --- /dev/null +++ b/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"} diff --git a/docs/modules/ROOT/images/solutions/wol/preview.png b/docs/modules/ROOT/images/solutions/wol/preview.png new file mode 100644 index 0000000..27fbaf3 Binary files /dev/null and b/docs/modules/ROOT/images/solutions/wol/preview.png differ diff --git a/docs/modules/ROOT/images/ssh-diagram.png b/docs/modules/ROOT/images/ssh-diagram.png new file mode 100644 index 0000000..ff11170 Binary files /dev/null and b/docs/modules/ROOT/images/ssh-diagram.png differ diff --git a/docs/modules/ROOT/images/stream-deck/config.png b/docs/modules/ROOT/images/stream-deck/config.png new file mode 100644 index 0000000..0091379 Binary files /dev/null and b/docs/modules/ROOT/images/stream-deck/config.png differ diff --git a/docs/modules/ROOT/images/stream-deck/inputs.png b/docs/modules/ROOT/images/stream-deck/inputs.png new file mode 100644 index 0000000..63b60c2 Binary files /dev/null and b/docs/modules/ROOT/images/stream-deck/inputs.png differ diff --git a/docs/modules/ROOT/images/stream-deck/marketplace.png b/docs/modules/ROOT/images/stream-deck/marketplace.png new file mode 100644 index 0000000..aaa686a Binary files /dev/null and b/docs/modules/ROOT/images/stream-deck/marketplace.png differ diff --git a/docs/modules/ROOT/images/stream-deck/panel.png b/docs/modules/ROOT/images/stream-deck/panel.png new file mode 100644 index 0000000..bda18a8 Binary files /dev/null and b/docs/modules/ROOT/images/stream-deck/panel.png differ diff --git a/docs/modules/ROOT/images/topbar.png b/docs/modules/ROOT/images/topbar.png new file mode 100644 index 0000000..347b465 Binary files /dev/null and b/docs/modules/ROOT/images/topbar.png differ diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc new file mode 100644 index 0000000..29896a8 --- /dev/null +++ b/docs/modules/ROOT/nav.adoc @@ -0,0 +1,183 @@ +* xref:install/intro.adoc[Installation Guide] +*** Linux +**** xref:install/container_vs_service.adoc[Containers or Service?] +**** Linux Service +***** xref:install/linux_fedora.adoc[Fedora Linux] +***** xref:install/linux_alpine.adoc[Alpine Linux] +***** xref:install/linux_manjaro.adoc[Manjaro Linux] +***** xref:install/linux_arch.adoc[Arch Linux] +***** xref:install/linux_rpm.adoc[Generic .rpm based Linux] +***** xref:install/linux_deb.adoc[Generic .deb based Linux] +***** xref:install/targz.adoc[.tar.gz Install (manual)] +**** xref:install/container.adoc[Linux Container] +***** xref:install/podmandocker.adoc[Docker or Podman] +***** xref:install/docker_compose.adoc[Docker Compose] +***** xref:install/helm.adoc[Kubernetes with Helm] +***** xref:install/k8s.adoc[Kubernetes with Manifests] +*** xref:install/bsd.adoc[BSD] +*** xref:install/windows.adoc[Windows] +**** xref:install/windows_service.adoc[Windows Service] +*** xref:install/macos.adoc[macOS Desktop] +*** xref:install/macos_service.adoc[macOS Service] +*** xref:install/choose_package.adoc[All download options] +* Upgrade Guide +** xref:upgrade/2k3k.adoc[Understanding 2k vs 3k] +** xref:upgrade/upgrade_notes.adoc[Updates Notes] +** xref:upgrade/github_latest.adoc[Warning: GitHub Latest] +** xref:reference/updateChecks.adoc[Update Checks] +** xref:reference/updateTracking.adoc[Update Tracking (legacy)] +* xref:config.adoc[Configuration] +* xref:reverse-proxies/intro.adoc[Reverse Proxies] +** xref:reverse-proxies/apache.adoc[Apache] +** xref:reverse-proxies/caddy.adoc[Caddy] +** xref:reverse-proxies/haproxy.adoc[HAProxy] +** xref:reverse-proxies/nginx.adoc[Nginx] +** xref:reverse-proxies/nginx_proxy_manager.adoc[Nginx Proxy Manager] +** xref:reverse-proxies/traefik.adoc[Traefik] +* Action Buttons +** xref:action_buttons/layout.adoc[Layout] +** xref:action_buttons/create_your_first.adoc[Create your first action] +* Action Execution +** xref:action_execution/shellvsexec.adoc[Shell vs Exec] +** xref:action_execution/ondemand.adoc[Execute on click] +** xref:action_execution/oncron.adoc[Execute on schedule (cron)] +** xref:action_execution/onstartup.adoc[Execute on startup] +** xref:action_execution/onwebhook.adoc[Execute on webhook] +*** xref:action_execution/onwebhook_github.adoc[GitHub Webhooks] +** xref:action_execution/onfilecreated.adoc[Execute on file created] +** xref:action_execution/onfilechanged.adoc[Execute on file changed] +** xref:action_execution/oncalendar.adoc[Execute on calendar file] +** xref:action_execution/aftercompletion.adoc[Execute after completion] +** xref:action_execution/triggers.adoc[Triggers] +* xref:action_customization/intro.adoc[Action Customization] +** xref:action_customization/icons.adoc[Icons] +** xref:action_customization/timeouts.adoc[Timeouts] +** xref:action_customization/users.adoc[Users] +** xref:action_customization/concurrency.adoc[Concurrency] +** xref:action_customization/ratelimiting.adoc[Rate Limiting] +** xref:action_customization/enabledExpression.adoc[Enabled Expression] +** xref:action_customization/ids.adoc[IDs] +* xref:action_examples/intro.adoc[Action Examples] +** xref:action_examples/containers.adoc[Containers - start/stop] +*** xref:action_examples/docker-proxy.adoc[Docker control, via proxy] +** xref:action_examples/systemd_service.adoc[Systemd Service] +** xref:action_examples/ping.adoc[Ping] +** xref:action_examples/ssh-easy.adoc[SSH (easy)] +** xref:action_examples/ssh-manual.adoc[SSH (manual)] +** xref:action_examples/powershell.adoc[Powershell] +** xref:action_examples/ansible.adoc[Ansible] +* xref:args/intro.adoc[Arguments] +** xref:args/safety.adoc[Safety] +** xref:args/types.adoc[Types] +** xref:args/input.adoc[Input] +** xref:args/regex.adoc[Input: Regex] +** xref:args/password.adoc[Input: Password] +** xref:args/input_checkbox.adoc[Input: Checkbox/Boolean] +** xref:args/input_dropdown.adoc[Input: Dropdown] +** xref:args/input_datetime.adoc[Input: Date & Time] +** xref:args/input_confirmation.adoc[Input: Confirmation] +** xref:args/input_textarea.adoc[Input: Textarea] +** xref:args/suggestions.adoc[Suggestions] +** xref:args/env.adoc[Environment Variables] +** xref:args/templates.adoc[Templates] +* xref:dashboards/intro.adoc[Dashboards] +** xref:dashboards/examples.adoc[Examples] +** xref:dashboards/actions.adoc[Actions (Linked)] +** xref:dashboards/inline-actions.adoc[Actions (Inline)] +** xref:dashboards/css.adoc[Change component style] +** xref:dashboards/2-fieldsets.adoc[Fieldsets] +** xref:dashboards/3-folders.adoc[Folders] +** xref:dashboards/4-displays.adoc[Displays] +** xref:dashboards/faq-display-hyperlinks.adoc[Hyperlinks (Displays)] +** xref:dashboards/5-output-views.adoc[Output Views] +** xref:dashboards/entity-directories.adoc[Entity Directories] +* xref:logs/intro.adoc[Logs] +** xref:logs/actions.adoc[Action logs] +** xref:logs/calendar.adoc[Calendar view] +** xref:logs/queue.adoc[Queue view] +** xref:logs/saving.adoc[Saving logs] +* xref:entities/intro.adoc[Entities] +** xref:entities/examples.adoc[Examples] +** xref:entities/yaml.adoc[YAML Entity Files] +** xref:entities/json.adoc[JSON Entity Files] +* xref:security/concepts.adoc[Security] +** xref:security/acl.adoc[Access Control Lists] +** xref:security/local.adoc[Local Users Authorization] +** xref:security/api_keys.adoc[API Keys] +** xref:security/trusted_header.adoc[Trusted Header Authorization] +** xref:security/jwt.adoc[JWT Authorization] +*** xref:security/jwt_keys.adoc[JWT with Keys] +*** xref:security/jwt_hmac.adoc[JWT with HMAC] +** xref:security/oauth2.adoc[OAuth2] +*** xref:security/oauth2_authentik.adoc[OAuth2 with Authentik] +*** xref:security/oauth2_authelia.adoc[OAuth2 with Authelia] +*** xref:security/oauth2_pocketid.adoc[OAuth2 with Pocket ID] +** xref:security/examples.adoc[Security examples] +*** xref:security/example_login_required.adoc[Example: Login Required] +*** xref:security/example_some_admin_actions.adoc[Example: Some actions require admin] +** xref:security/design_choices.adoc[Security Design & Hardening Recommendations] +** xref:security/content_security_policy.adoc[Content Security Policy headers] +* Integrations +** xref:integrations/homeassistant-integration.adoc[Home Assistant (HACS Integration)] +** xref:integrations/homeassistant.adoc[Home Assistant (REST)] +** xref:integrations/stream-deck.adoc[Stream-Deck] +** xref:integrations/n8n.adoc[n8n] +** xref:integrations/mcp.adoc[MCP Servers] +** xref:security/oauth2_authentik.adoc[Authentik] +** xref:security/oauth2_pocketid.adoc[Pocket ID] +** xref:reverse-proxies/intro.adoc[Reverse Proxies] +* xref:solutions/intro.adoc[Solutions] +** xref:security/examples.adoc[Security examples] +** xref:solutions/on-git-push/index.adoc[Self hosted GitOps] +** xref:solutions/container-control-panel/index.adoc[Container Control Panel] +** xref:solutions/systemd-control-panel/index.adoc[Systemd Control Panel] +** xref:solutions/heating-control-panel/index.adoc[Heating Control Panel] +** xref:solutions/human-in-the-control-loop/index.adoc[Human in the Control Loop] +** xref:solutions/k8s-control-panel-hosted/index.adoc[Kubernetes Control Panel (hosted)] +** xref:solutions/primitive-password/index.adoc[Primitive Password Protection] +** xref:solutions/wol/index.adoc[Wake on LAN] +** xref:solutions/cloudflare_access_tunnel/index.adoc[Cloudflare Access & Tunnels] +** xref:solutions/directory-actions/index.adoc[Directory Actions] +* xref:advanced_configuration/intro.adoc[Advanced Configuration] +** xref:advanced_configuration/logs.adoc[Logging - Application] +** xref:advanced_configuration/diagnostics.adoc[Diagnostics] +** xref:advanced_configuration/config_envs.adoc[Config Envs] +** xref:advanced_configuration/ports.adoc[Ports] +** xref:advanced_configuration/stylemods.adoc[Style Mods] +** xref:advanced_configuration/prometheus.adoc[Prometheus] +** xref:advanced_configuration/timezones.adoc[Timezones] +** xref:advanced_configuration/webui.adoc[Customize the WebUI] +* Reference +** xref:reference/network-ports.adoc[Network Ports] +** xref:reference/exitCodes.adoc[Exit Codes] +** xref:reference/containerInstallPackages.adoc[Install packages in containers] +** xref:reference/reference_snapshots.adoc[Snapshots] +** xref:reference/reference_themes_for_users.adoc[Themes (for users)] +** xref:reference/reference_themes_for_developers.adoc[Themes (for developers)] +** xref:reference/contribute.adoc[Contribute] +** xref:reference/donations_and_sponsorship.adoc[Donations & Sponsorship] +** xref:reference/multiple_instances.adoc[Multiple Instances] +** xref:reference/release_policy.adoc[Release Policy] +** xref:reference/includes.adoc[Includes] +* Troubleshooting +** xref:troubleshooting/wheretofindhelp.adoc[Where to find help] +** xref:troubleshooting/browser-console-logs.adoc[Browser console logs (WebUI)] +** xref:troubleshooting/service-logs.adoc[Service logs (OliveTin process)] +** xref:troubleshooting/server-diagnostics.adoc[Server diagnostics] +** xref:troubleshooting/puid-pgid.adoc[No PUID/PGID support] +** xref:troubleshooting/log-debug-options.adoc[Log Debug Options] +** xref:troubleshooting/exit127.adoc[Exit Code 127] +** xref:troubleshooting/err-fetch-webui-settings.adoc[Error: WebUI Settings] +** xref:troubleshooting/err-fetch-buttons.adoc[Error: Fetch Buttons] +** xref:troubleshooting/err-js-modules-not-supported.adoc[Error: JS Modules not supported] +** xref:troubleshooting/err-websocket-connection.adoc[Error: Websocket Connection] +** xref:troubleshooting/err-webui-mismatch.adoc[Error: WebUI Version Mismatch] +** xref:troubleshooting/advanced.adoc[Advanced Troubleshooting] +* xref:api/intro.adoc[API] +** xref:api/start_action.adoc[Start Actions from the API] +*** xref:api/method_StartAction.adoc[StartAction] +*** xref:api/method_StartActionByGet.adoc[StartActionByGet] +*** xref:api/method_StartActionAndWait.adoc[StartActionAndWait] +*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait] +** xref:api/misc.adoc[Misc API calls] +** xref:api/login.adoc[Local user login via the API] diff --git a/docs/modules/ROOT/pages/action_buttons/create_your_first.adoc b/docs/modules/ROOT/pages/action_buttons/create_your_first.adoc new file mode 100644 index 0000000..a2d3677 --- /dev/null +++ b/docs/modules/ROOT/pages/action_buttons/create_your_first.adoc @@ -0,0 +1,94 @@ +[#create-your-first-action] += Create your first action + +This page walks through adding your first action button — the step that turns a fresh OliveTin install into something you can actually click and use. + +When you are done, your dashboard will have a **Say Hello** button like this: + +image::action_buttons/create_your_first/hello-world.png[] + +== Before you start + +Make sure you have: + +* OliveTin **installed and running** — see the xref:install/intro.adoc[installation guide] if you have not done this yet +* Access to your OliveTin **`config.yaml`** file — see xref:config.adoc#config[Configuration] for where OliveTin looks for this file on your platform +* A text editor to change the file (any editor is fine) + +When OliveTin starts successfully, open the web interface in your browser (by default at `http://localhost:1337/`). You should see the OliveTin dashboard, even if it does not have any custom actions yet. + +== Step 1: Open `config.yaml` + +OliveTin is controlled entirely by `config.yaml`. On startup it looks for this file in several places — most commonly: + +* The directory you pass with `--configdir` (often the current working directory when you start OliveTin manually) +* `/config/` inside containers +* `/etc/OliveTin/` on Linux service installs + +If you are not sure which file your instance uses, check how you installed OliveTin (container, package, or manual) and open the `config.yaml` in that location. + +TIP: The xref:config.adoc[Configuration] page lists every search path and explains how live reload works when you save changes. + +== Step 2: Add an action + +Add an entry under `actions`. Each action needs at least a **title** (shown on the button) and a **shell** command to run: + +.`config.yaml` +[source,yaml] +---- +actions: + - title: Say Hello + shell: echo "Hello World!" + icon: smile + onclick: execution-dialog +---- + +* `title` — the label on the action button. It must be unique across all actions. +* `shell` — the command OliveTin runs when you click the button. Here it prints `Hello World!` to the output. +* `icon` — the glyph shown in the centre of the button. See xref:action_customization/icons.adoc[Icons] for other options. +* `onclick: execution-dialog` — opens a dialog with the command output when the action runs, so you can see straight away that it worked. See xref:action_execution/ondemand.adoc[Execute on click] for other options. + +If your `config.yaml` already has other settings or actions, add this block alongside them. Only the `actions:` list is required for this example. + +== Step 3: Save the file + +Save `config.yaml`. OliveTin watches the file and **reloads configuration automatically** when it changes — you do not need to restart the service in most setups. + +Refresh the web page in your browser so the dashboard picks up the new action. + +NOTE: If the button does not appear after saving, check the OliveTin application logs for YAML syntax errors, then refresh again. A missing quote or incorrect indentation in `config.yaml` is the most common cause. + +== Step 4: Find your new button + +After reload, a new **Say Hello** button appears on the dashboard: + +image::action_buttons/create_your_first/hello-world.png[] + +Each action button shows the title at the bottom and the icon in the centre. The small icon in the top-right corner indicates that clicking opens an execution dialog. See xref:action_buttons/layout.adoc[Layout] for a breakdown of every part of the button. + +== Step 5: Run the action + +Click **Say Hello**. OliveTin runs `echo "Hello World!"` and opens the execution dialog with the output, timing, and exit code. + +If the dialog shows `Hello World!` and a successful exit code, your first action is working. + +== Step 6: View the logs + +Every execution is also recorded in the xref:logs/intro.adoc[Logs] section of the web interface. Open **Logs** in the navigation to browse past runs, search for executions, and open full output again later. + +== Important considerations + +* The action **title must be unique**. If two actions share the same title, only one button is shown. +* The `shell` field runs your command through a shell. For more control (especially with arguments), use `exec` instead — see xref:action_execution/shellvsexec.adoc[Shell vs Exec]. + +== What's Next? + +Now that you have a working action, try: + +* xref:action_buttons/layout.adoc[Layout] — understand the parts of an action button +* xref:action_customization/intro.adoc[Customize your actions] — icons, timeouts, and other action properties +* xref:args/intro.adoc[Add arguments to actions] — make actions interactive with user input +* xref:action_examples/intro.adoc[Browse action examples] — real-world examples for common use cases +* xref:action_execution/oncron.adoc[Schedule actions] — run actions automatically on a schedule +* xref:action_execution/onwebhook.adoc[Trigger actions via webhooks] — integrate OliveTin with external systems +* xref:dashboards/intro.adoc[Organize actions with dashboards] — create custom views to organize your actions diff --git a/docs/modules/ROOT/pages/action_buttons/layout.adoc b/docs/modules/ROOT/pages/action_buttons/layout.adoc new file mode 100644 index 0000000..283bb73 --- /dev/null +++ b/docs/modules/ROOT/pages/action_buttons/layout.adoc @@ -0,0 +1,40 @@ +[#action-button-layout] += Layout + +Each action appears in the OliveTin web interface as a button card. The screenshot below shows three buttons in different states so you can see each part of the layout. + +image::action_buttons/layout/layout.png[] + +== Action title + +The **title** comes from the action's `title` field in `config.yaml`. It is shown at the bottom of the button and is also used as the tooltip when you hover over the button. + +== Icon + +The large glyph in the centre of the button is the action **icon**. You set this with the `icon` field on the action. + +If you do not set `icon`, OliveTin uses the default action icon. See xref:action_customization/icons.adoc[Icons] for Unicode, Iconify, and image options. + +== On-click indicator + +The small icon in the **top-right corner** of the button is the **on-click indicator**. It shows what happens when the action starts — for example, opening an execution dialog, an argument form, or action history. + +The indicator is controlled by the action's `onclick` setting (legacy name: `popupOnStart`). See xref:action_execution/ondemand.adoc[Execute on click] for the available options. + +You can hide these indicators globally with `showNavigateOnStartIcons: false` in `config.yaml`. + +== Running and queued indicators + +When an action is already running or waiting in a queue, OliveTin shows a small circle in the **top-left corner** of the button: + +* **Green** — the action has a running execution +* **Blue** — the action has an execution waiting in a queue + +These indicators appear when concurrency or xref:action_customization/concurrency.adoc[action groups] limit how many executions can run at once. They disappear when the execution finishes. + +In the screenshot above, **Long task** is running and **Backup job** is queued. + +== See also + +* xref:action_buttons/create_your_first.adoc[Create your first action] +* xref:action_customization/intro.adoc[Action customization] diff --git a/docs/modules/ROOT/pages/action_customization/concurrency.adoc b/docs/modules/ROOT/pages/action_customization/concurrency.adoc new file mode 100644 index 0000000..ad3649d --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/concurrency.adoc @@ -0,0 +1,72 @@ +[#concurrency] += Concurrency + +By default, OliveTin will allow you to run several instances of an action at the same time. For example, an action might take 20 seconds, and if you click the button 3 times, for a time there will be 3 actions running at the same time. + +Sometimes you don't want to allow this - an example case where it would not make sense is in the case of a backup script. To stop this, we can set `maxConcurrent` to `1`. + +[source,yaml] +---- +actions: + - title: Run Backup Script + icon: backup + shell: /opt/backupScript.sh + maxConcurrent: 1 +---- + +If you try and run a 2nd instance of this action while the first is currently running, you'll get a "blocked" message that looks like this; + +image::../blocked.png[] + +Additionally, OliveTin will log a message that looks like this; + +[source,log] +.OliveTin log showing an action being blocked rom running. +---- +INFO Action requested actionTitle="Run backup script" +WARN Blocked from executing. This would mean this action is running 2 times concurrently, but this action has maxExecutions set to 1. actionTitle="Run backup script" +---- + +Naturally, you can set `maxConcurrent` to `3` or some other number, to limit the amount of times the action executes at once. + +== Action groups + +Sometimes you need to limit concurrency across several different actions. For example, Unity only allows one build at a time, but you might have separate actions for different platforms. + +Use `actionGroups` to define a shared limit, and assign actions to a group with `groups`: + +[source,yaml] +---- +actionGroups: + unity: + maxConcurrent: 1 + queueSize: 5 + +actions: + - title: Unity Android Build + shell: /opt/unity/build-android.sh + groups: [ unity ] + + - title: Unity iOS Build + shell: /opt/unity/build-ios.sh + groups: [ unity ] +---- + +=== maxConcurrent vs queueSize + +Action groups define two related limits: + +* `maxConcurrent` -- how many executions in the group may *run at the same time*. When every slot is in use, new requests wait for a free slot instead of running immediately. +* `queueSize` -- how many executions may *wait in the queue* for a slot. If the group is full and the queue already holds this many waiting executions, additional requests are blocked (the same "blocked" status as per-action concurrency). + +For example, with `maxConcurrent: 1` and `queueSize: 5`, OliveTin can have one execution running and up to five more waiting. A seventh request while those six are still outstanding is blocked. + +`queueSize` defaults to `5` when omitted. + +When the group limit is reached but the queue is not full, additional requests are queued automatically and run in order when a slot becomes free. Queued executions appear in the logs with a queued status. + +Per-action `maxConcurrent` still applies to actions that are not in a group. For actions in a group, concurrency is governed by the group `maxConcurrent` and `queueSize` settings instead. A second request for the same action may run concurrently when the group has spare capacity, or join the queue when the group is full. + +Actions that are not in a group are never queued. They only use their own `maxConcurrent` limit (default `1`). + +The queue is held in memory. If OliveTin restarts while actions are queued, those queued requests are not preserved. diff --git a/docs/modules/ROOT/pages/action_customization/enabledExpression.adoc b/docs/modules/ROOT/pages/action_customization/enabledExpression.adoc new file mode 100644 index 0000000..9f76fb5 --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/enabledExpression.adoc @@ -0,0 +1,189 @@ +[#enabled-expression] += Enabled Expression + +The `enabledExpression` property allows you to dynamically enable or disable action buttons based on entity properties. This is useful when you want to show context-appropriate actions - for example, only showing a "Turn Off" button when a device is already on, or only allowing a "Start" action when a service is stopped. + +== Basic Usage + +The `enabledExpression` is a Go template that must evaluate to a boolean value. When the expression evaluates to `true`, the action button will be enabled (clickable). When it evaluates to `false`, the button will be disabled (greyed out and not clickable). + +[source,yaml] +---- +actions: + - title: Turn On Light + shell: echo "Turning on {{ .CurrentEntity.name }}" + icon: 💡 + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on false }}" + + - title: Turn Off Light + shell: echo "Turning off {{ .CurrentEntity.name }}" + icon: 💡 + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on true }}" +---- + +In this example: + +* The "Turn On Light" button is only enabled when `powered_on` is `false` +* The "Turn Off Light" button is only enabled when `powered_on` is `true` + +== How It Works + +The `enabledExpression` uses the same Go template syntax used elsewhere in OliveTin. It has access to the `.CurrentEntity` variable which contains all properties of the entity the action is bound to. + +=== Result Evaluation + +The template result is evaluated as follows: + +[cols="1,1", options="header"] +|=== +| Result | Enabled? +| `true` (case insensitive) | ✓ Yes +| Non-zero integer (e.g., `1`, `42`) | ✓ Yes +| `false` (case insensitive) | ✗ No +| `0` | ✗ No +| Empty string | ✗ No +| Template error | ✗ No +|=== + +=== Default Behavior + +If `enabledExpression` is not specified, the action is always enabled (assuming the user has permission to execute it via ACLs). + +== Examples + +=== Simple Boolean Check + +[source,yaml] +---- +actions: + - title: Start Service + shell: systemctl start {{ .CurrentEntity.service_name }} + entity: service + enabledExpression: "{{ eq .CurrentEntity.running false }}" + + - title: Stop Service + shell: systemctl stop {{ .CurrentEntity.service_name }} + entity: service + enabledExpression: "{{ eq .CurrentEntity.running true }}" +---- + +=== Checking Status Values + +[source,yaml] +---- +actions: + - title: Resume Download + shell: resume-download {{ .CurrentEntity.id }} + entity: download + enabledExpression: "{{ eq .CurrentEntity.status \"paused\" }}" +---- + +=== Using Integer Status Codes + +If your entity has integer status values, you can use them directly: + +[source,yaml] +---- +actions: + - title: Process Item + shell: process {{ .CurrentEntity.id }} + entity: item + # Status 1 means "ready" - action is enabled when status is 1 + enabledExpression: "{{ .CurrentEntity.status }}" +---- + +=== Combining with Other Template Functions + +You can use Go template functions for more complex logic: + +[source,yaml] +---- +actions: + - title: Deploy to Production + shell: deploy {{ .CurrentEntity.name }} + entity: service + # Only enable if status is "ready" AND environment is "staging" + enabledExpression: "{{ and (eq .CurrentEntity.status \"ready\") (eq .CurrentEntity.environment \"staging\") }}" +---- + +== Complete Example + +Here's a complete configuration showing `enabledExpression` with entities and dashboards: + +[source,yaml] +---- +entities: + - file: /etc/OliveTin/lights.yaml + name: light + +actions: + - title: Turn On Light + shell: /opt/smart-home/light-control.sh on {{ .CurrentEntity.id }} + icon: 💡 + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on false }}" + + - title: Turn Off Light + shell: /opt/smart-home/light-control.sh off {{ .CurrentEntity.id }} + icon: 🔌 + entity: light + enabledExpression: "{{ eq .CurrentEntity.powered_on true }}" + +dashboards: + - title: Light Controls + contents: + - title: Lights + type: fieldset + entity: light + contents: + - type: display + title: | + {{ .CurrentEntity.name }} + - title: Turn On Light + - title: Turn Off Light +---- + +With an entity file like: + +[source,yaml] +./etc/OliveTin/lights.yaml +---- +- id: kitchen + name: Kitchen Light + powered_on: false + +- id: living_room + name: Living Room Light + powered_on: true +---- + +In this setup: + +* The Kitchen Light will have "Turn On" enabled and "Turn Off" disabled +* The Living Room Light will have "Turn Off" enabled and "Turn On" disabled + +== Error Handling + +If the `enabledExpression` template fails to parse or execute (e.g., due to syntax errors or missing entity properties), OliveTin will: + +1. Log a warning message with details about the failure +2. Treat the action as **disabled** for safety + +This ensures that misconfigured expressions don't accidentally allow unintended actions. + +== Relationship with ACLs + +The `enabledExpression` works in combination with xref:security/acl.adoc[Access Control Lists (ACLs)]. An action button is only enabled when **both** conditions are met: + +1. The user has `exec` permission via ACLs +2. The `enabledExpression` evaluates to `true` + +If either condition is not met, the action button will be disabled. + +== See Also + +* xref:entities/intro.adoc[Entities] - Learn about defining entities +* xref:dashboards/intro.adoc[Dashboards] - Display entity-bound actions +* xref:security/acl.adoc[Access Control Lists] - Control who can execute actions diff --git a/docs/modules/ROOT/pages/action_customization/icons.adoc b/docs/modules/ROOT/pages/action_customization/icons.adoc new file mode 100644 index 0000000..aff4715 --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/icons.adoc @@ -0,0 +1,158 @@ +[#icons] += Icons + +You can specify any HTML for an icon. It's a popular choice to use Unicode +icons because they are extremely fast to load and there are a lot of them, +but OliveTin also support Iconify, and simple PNG, JPG, WEBP and similar images. + +.Examples of icons in OliveTin +image::../exampleIcons.png[] + +For a quick reference, here are some examples of how to use different types of icons in OliveTin; + +.`config.yaml` +```yaml +include::example$action_customization/icons/config.yaml[] +``` + +== Iconify Icons + +Browse over 200,000 icons that can be used with OliveTin here; https://icon-sets.iconify.design/ + +Note, the icons are loaded from the internet, but should be cached by your browser afer the first load. + +On the Iconfiy website, you should select **Iconify Icon** + +image::../iconify.png[] + +Then copy this icon code, and place it in your config; + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Iconify Icon + icon: +---- + +And you should get something that looks like this; + +image::../action-button-iconify.png[] + +== Default Icon (bundled HugeIcon) + +OliveTin used to use a default emoji smiley face as the default icon for actions, but that was a bit too "emoji" and not everyone liked it. Now, OliveTin uses a simple "command line" icon from the HugeIcons set as the default icon for actions. This is a simple and neutral icon that should work well for most actions. + +If you need to reset a default icon for some reason, this is how you can do it; + +.`config.yaml` +---- +actions: + - title: Action with the bundled CLI HugeIcon + icon: hugeicons:CommandLineIcon + shell: echo hello +---- + +If you want to use other icons from the HugeIcons set, you need to use the Iconify method described above, not with the "hugeicons:" prefix - that only works for the default icon. + +== Unicode icons ("emoji") + +Using simple emoji (unicode) icons from your browser's font is extremely fast, and can look good on some platforms. However, the icons are platform specific, which mean's they'll look different between browsers and between operating systems. + +There are great sites like link:https://symbl.cc/en/emoji/[symbl.cc - a list of +"Emoji" in unicode]. + +For example, if you find "link:https://symbl.cc/en/1F60E/[Smiling face with sunglasses]" you can click +on it to see it's "HTML-code". In OliveTin, you'd setup the icon like this; + +---- +actions: + - title: Unicode (emoji) icon + icon: "😎" + shell: echo "You are awesome" +---- + +=== Unicode aliases + +OliveTin has hard-coded aliases for a few commonly used icons, so you don't have to type out the full unicode codes. A list of those hard coded icons is; + +.Alias'd unicode reference table +[%header] +|=== +| Alias | Rendered as + +| `poop` | 💩 +| `smile` | 😀 +| `ping` | 📡 +| `backup` | 💾 +| `reboot` | 🔄 +| `restart` | 🔄 +| `box` | 📦 +| `ashtonished` | 😲 +| `clock` | 🕒 +| `disk` | 💽 +| `logs` | 🔍 +| `light` | 💡 +| `robot` | 🤖 +| `ssh` | 🔐 +| `theme` | 🎨 +|=== + +A full reference can be found in: https://github.com/OliveTin/OliveTin/blob/main/service/internal/config/emoji.go + +== Full HTML icons (`' + shell: docker ps +---- + +=== Saving and serving icons for "offline" use + +Sometimes you might want to store images to use as icons, with your installation of OliveTin. This can be useful when your installation is meant to be offline, or disconnected from the internet. This is easily done. + +OliveTin will try to create a directory called `custom-webui` in the same directory as the `config.yaml` file. If this directory exists, OliveTin will serve files from this directory as if they were in the standard webui directory, in the same path as your OliveTin web UI. + +Ideally, put your icons in a directory like `/custom-webui/icons/`. If this directory contained a file called "mrgreen.gif", then it would be served at `http://myserver:1337/custom-webui/icons/mrgreen.gif`. Below is a picture of Mr Green. Feel free to save his likeness and awesomeness for yourself, for future awesome offline usage. + +.Mr Green, the original awesome smily. +image::../mrgreen.gif[Mr Green] + +In your OliveTin config, customize your command again using HTML, like this; + +---- +actions: + - title: Mr Green + icon: '' + shell: echo "I don't like the word 'emoji' " +---- + +This will result in a locally hosted icon that will work offline, that looks like this; + +image::../mrGreenAction.png[] + +//// += CSS styles + +OliveTin allows you to write any CSS style rules directly on a single action. +This is both pretty powerful if you want an action to have a particular style, +but it does require understanding that you are writing your code - and can +break things! Be careful! + +A tutorial on how to use CSS can easily be found online, but here are some +examples; + +== Example: Bold & Purple action + +---- +- actions: + - title: My special action + css: + background-color: purple + font-weight: bold + shell: echo "I like purple" +---- +//// diff --git a/docs/modules/ROOT/pages/action_customization/ids.adoc b/docs/modules/ROOT/pages/action_customization/ids.adoc new file mode 100644 index 0000000..949a94f --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/ids.adoc @@ -0,0 +1,16 @@ +[#action-ids] += Action IDs + +OliveTin actions do not require IDs to be specified in the `config.yaml`, as most users of OliveTin start off with the Web Interface. However, if you want to use OliveTin actions via the xref:api/intro.adoc[API], then you will need to set your action IDs manually. + +NOTE: OliveTin will automatically generate a new ID for actions every time it starts up, for actions that don't have an `id:` property set. + +[source,yaml] +---- +actions: + - title: Start the reactor + id: start_reactor + shell: /bin/startReactor.sh +---- + + diff --git a/docs/modules/ROOT/pages/action_customization/intro.adoc b/docs/modules/ROOT/pages/action_customization/intro.adoc new file mode 100644 index 0000000..a3ce143 --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/intro.adoc @@ -0,0 +1,30 @@ +[#action-customisation] += Action customisation + +Actions in OliveTin can be customized in many ways to fit your specific needs. This section covers various customization options that allow you to control how actions behave, appear, and execute. + +You can customize actions by: + +* Setting icons to make actions visually distinct +* Configuring timeouts to control how long actions can run +* Assigning actions to specific users or groups +* Controlling concurrency to limit how many instances of an action can run simultaneously +* Setting rate limits to prevent actions from being executed too frequently +* Using enabled expressions to dynamically enable/disable actions based on entity state +* Assigning unique IDs for API access +* Configuring log saving for audit trails + +See the links in this section for detailed information on each customization option. + +== What's Next? + +Explore specific customization options: + +* xref:action_customization/icons.adoc[Customize icons] - Set visual icons for your actions +* xref:action_customization/timeouts.adoc[Configure timeouts] - Control how long actions can run +* xref:action_customization/users.adoc[Assign to users] - Restrict actions to specific users +* xref:action_customization/concurrency.adoc[Control concurrency] - Limit simultaneous executions +* xref:action_customization/ratelimiting.adoc[Set rate limits] - Prevent actions from running too frequently +* xref:action_customization/enabledExpression.adoc[Enabled expressions] - Dynamically enable/disable actions based on entity state +* xref:logs/saving.adoc[Save action logs] - Configure log retention for actions +* xref:action_customization/ids.adoc[Set action IDs] - Assign IDs for API access diff --git a/docs/modules/ROOT/pages/action_customization/ratelimiting.adoc b/docs/modules/ROOT/pages/action_customization/ratelimiting.adoc new file mode 100644 index 0000000..a229d0d --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/ratelimiting.adoc @@ -0,0 +1,30 @@ +[#ratelimits] += Rate limiting + +By default, OliveTin allows you to execute actions as fast as you can click the button. This is fine if you are running OliveTin with trusted users in a trusted environment, but otherwise you may want to rate limit actions. + +Rate limiting is implemented like this; + +[source,yaml] +.`config.yaml` +---- +actions: + - title: date + shell: date + icon: clock + maxRate: + - limit: 3 + duration: 5m +---- + +If you try to execute `date` more than 3 times in 5 minutes, you will get a log message in the UI that looks like this; + +image::maxRate.png[] + +Additionally, OliveTin will also output this to it's process log; + +---- +INFO Blocked from executing. This action has run 3 out of 3 allowed times in the last 5m. actionTitle="date" +---- + + diff --git a/docs/modules/ROOT/pages/action_customization/timeouts.adoc b/docs/modules/ROOT/pages/action_customization/timeouts.adoc new file mode 100644 index 0000000..d79a3de --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/timeouts.adoc @@ -0,0 +1,28 @@ +[#timeout] += Timeouts + +By default, actions in OliveTin have a **3 second timeout** for all actions. +This means that OliveTin will kill the action if it is running for longer than +the timeout, which can be useful to stop commands running for a long time. + +You can set your own timeouts like this; + +[source,yaml] +---- +actions: + - title: My special action + shell: sleep 5 + timeout: 10 +---- + +NOTE: Allowing commands to run for infinity just doesn't seem to make sense, or +at least is probably a bad case for OliveTin. Therefore, if you set a timeout +*less than 3 seconds*, OliveTin will overwrite your Timeout and default to 3 +seconds. If you think you have a use case where a shorter (or infinite) timeout +makes sense, please open an issue and let's discuss. + +== Check the logs + +If a action really does "time out", it will show in the logs with "(timed out)" next to the exist code; + +image::action_customization/timeout-logs/timeoutLogs.png[] diff --git a/docs/modules/ROOT/pages/action_customization/users.adoc b/docs/modules/ROOT/pages/action_customization/users.adoc new file mode 100644 index 0000000..2546412 --- /dev/null +++ b/docs/modules/ROOT/pages/action_customization/users.adoc @@ -0,0 +1,26 @@ += Run as different users + +OliveTin does not *need* to run as root. It does not request any special +permissions from the operating system that require root (as long as you run on +ports above 1024, and it can read/write it's configuration). So, you can run as +any non-root user if you wish. + +However, it is very convenient to run as root, as many users will need to run +actions and jobs that do require root permissions. + +There are no ways in OliveTin to specify which user runs an action, because the +Linux OS has several great ways to do this already, and adding support for it +in OliveTin just adds bloat when there are perfectly good ways that already +exist. + +== EG: Using sudo; + +---- +actions: + - title: Run echo as a different user + shell: sudo -u bob echo "I am Bob." +---- + +If you are worried about security, you could run OliveTin as a non-privileged +user, and use sudo rules to control what it can and cannot do. + diff --git a/docs/modules/ROOT/pages/action_examples/ansible.adoc b/docs/modules/ROOT/pages/action_examples/ansible.adoc new file mode 100644 index 0000000..d1c1805 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/ansible.adoc @@ -0,0 +1,21 @@ +[#ansible-playbook] += Ansible Playbooks + +:systemd: Easy +:container: Install the `ansible` package in your xref:reference/containerInstallPackages.adoc[OliveTin container]. +include::partial$action_examples/actionHeader.adoc[] + +Many users use OliveTin to easily execute Ansible playbooks, somtimes as a simple alternative to AWX. + +.Run an Ansible Playbook +[source,yaml] +---- +actions: + - title: Run Ansible Playbook + icon: "🇦" + shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml + timeout: 120 +---- + +You probably want to set the xref:action_customization/timeouts.adoc[timeout] to more than the default 3 seconds. + diff --git a/docs/modules/ROOT/pages/action_examples/containers.adoc b/docs/modules/ROOT/pages/action_examples/containers.adoc new file mode 100644 index 0000000..6335998 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/containers.adoc @@ -0,0 +1,21 @@ +[#action-container-control] += Containers - start/stop + +NOTE: There is a complete example of how to setup a xref:solutions/container-control-panel/index.adoc[container control panel] in the solutions section. + +:systemd: Easy +:container: Setup needed - see below +include::partial$action_examples/actionHeader.adoc[] + +[#example-control-containers] +.... +actions: + - title: Stop Plex + shell: docker stop plex + + - title: Start plex + shell: docker start plex +.... + +include::partial$container_socket.adoc[] + diff --git a/docs/modules/ROOT/pages/action_examples/docker-proxy.adoc b/docs/modules/ROOT/pages/action_examples/docker-proxy.adoc new file mode 100644 index 0000000..adfd710 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/docker-proxy.adoc @@ -0,0 +1,40 @@ +[#action-container-proxy] += Using the Docker socket proxy + +The OliveTin container comes with the official docker CLI pre-installed, as well as the compose plugin. This is because OliveTin is very often used to start and stop containers. + +You can choose to directly bind-mount the docker control socket into OliveTin, or optionally use a docker socket proxy host if you feel you need more security. You can use a docker socket proxy as an additional security measure and as an alternative to mounting the docker socket directly. + +Most people will want to add the docker socket proxy into the same compose file that they are running OliveTin from; + +[source,yaml] +.docker-compose.yaml +.... +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + ... + socket-proxy: + image: lscr.io/linuxserver/socket-proxy:latest + container_name: socket-proxy + environment: + - ALLOW_START=1 #optional + - ALLOW_STOP=1 #optional + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro +.... + +You can find all the documentation for all the socket-proxy options here on the link:https://github.com/linuxserver/docker-socket-proxy[LinuxServer.io socket-proxy page]. + +Assuming your docker socket proxy is running as `socket-proxy` running on port 1028; + +[source,yaml] +.OliveTin config.yaml +---- +actions: + - title: Stop container + shell: DOCKER_HOST=socket-proxy:1028 docker stop mycontainer +---- + diff --git a/docs/modules/ROOT/pages/action_examples/intro.adoc b/docs/modules/ROOT/pages/action_examples/intro.adoc new file mode 100644 index 0000000..725a1fb --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/intro.adoc @@ -0,0 +1,24 @@ += Action Examples + +This section provides practical examples of how to configure actions in OliveTin for common use cases. These examples demonstrate real-world scenarios and can serve as starting points for your own configurations. + +The examples cover: + +* Container management (starting, stopping, and managing containers) +* Systemd service control +* Network utilities like ping +* Remote execution via SSH +* PowerShell commands for Windows environments +* Integration with automation tools like Ansible + +Each example includes a complete configuration snippet that you can adapt for your environment. Browse through the examples to find scenarios that match your needs, or use them as inspiration for creating your own custom actions. + +== What's Next? + +After reviewing the examples, you can: + +* xref:action_buttons/create_your_first.adoc[Create your own action] - Build a custom action for your needs +* xref:action_customization/intro.adoc[Customize your actions] - Learn how to configure action properties +* xref:args/intro.adoc[Add arguments] - Make your actions interactive with user input +* xref:solutions/intro.adoc[Explore complete solutions] - See full configurations for common use cases +* xref:action_execution/shellvsexec.adoc[Understand shell vs exec] - Learn about execution methods for security diff --git a/docs/modules/ROOT/pages/action_examples/ping.adoc b/docs/modules/ROOT/pages/action_examples/ping.adoc new file mode 100644 index 0000000..d227fb5 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/ping.adoc @@ -0,0 +1,16 @@ +[#action-ping] += Ping an address + +:systemd: Easy +:container: Easy +include::partial$action_examples/actionHeader.adoc[] + +[source,yaml] +---- +actions: + # This sends 1 ping to google.com. + - title: ping google.com + shell: ping google.com -c 1 + icon: ping + timeout: 3 +---- diff --git a/docs/modules/ROOT/pages/action_examples/powershell.adoc b/docs/modules/ROOT/pages/action_examples/powershell.adoc new file mode 100644 index 0000000..262f265 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/powershell.adoc @@ -0,0 +1,17 @@ +[#powershell] += Powershell + +:systemd: Easy +:container: Not possible +include::partial$action_examples/actionHeader.adoc[] + +Powershell requires `pwsh` to execute commands. + +[source,yaml] +.`config.yaml` +.... +actions: + - title: Run Powershell Script: + shell: pwsh C:/Scripts/MyScript.ps1 +.... + diff --git a/docs/modules/ROOT/pages/action_examples/ssh-easy.adoc b/docs/modules/ROOT/pages/action_examples/ssh-easy.adoc new file mode 100644 index 0000000..310411d --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/ssh-easy.adoc @@ -0,0 +1,43 @@ +[#action-ssh-easy] += SSH (easy setup) + +include::partial$action_examples/ssh_intro.adoc[] + +NOTE: This is the easy method of setting up SSH with OliveTin - this generates a new SSH key for you, and a configuration file that disables SSH host key checking, to make it faster to do useful things with OliveTin. This is fine for most homelab setups, but if you are using OliveTin in a production environment, you should use the more secure method of setting up SSH and set it up manually, see xref:action_examples/ssh-easy.adoc[SSH (manual setup)]. + +== SSH from inside a Container - setup instructions + +* [red]#<># Use the olivetin-setup-easy-ssh script to generate a new SSH key and configuration file +** Add this key fingerprint to servers and hosts that you want to SSH to. +* [red]#<># Setup actions that use SSH with this configuration file (which points to the key) + +Visually, this is what it looks like - OliveTin is running in the (orange) container, and then can either connect back to _server-with-olivetin_ or _server2_. + +image::../ssh-diagram.png[] + +[#ssh-easy-step-1] +== [red]#Step 1#: Run the olivetin-setup-easy-ssh script + +Setup an action as follows, to use the builtin olivetin-setup-easy-ssh script that comes with OliveTin containers. This script does **not** work on Windows, MacOS, or outside of a container. + +.config.yaml +[source,yaml] +---- +actions: + - title: Setup SSH + shell: olivetin-setup-easy-ssh + onclick: execution-dialog +---- + +[#ssh-easy-step-2] +== [red]#Step 2#: Use the configuration file in your actions + +To use the configuration file generated by the script, you can use the following in your other actions: + +.config.yaml +[source,yaml] +---- +actions: + - title: SSH into a server + shell: ssh -F /config/ssh/config root@myserver '/opt/script-on-my-server.sh' +---- diff --git a/docs/modules/ROOT/pages/action_examples/ssh-manual.adoc b/docs/modules/ROOT/pages/action_examples/ssh-manual.adoc new file mode 100644 index 0000000..c5e67e5 --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/ssh-manual.adoc @@ -0,0 +1,159 @@ +[#action-ssh] += SSH (manual setup) + +include::partial$action_examples/ssh_intro.adoc[] + +NOTE: There is an easy method of setting up SSH with OliveTin, which is described in the <> section. This section is for those who want to set up SSH manually. + +:systemd: Easy +:container: Needs some setting up - see the <> +include::partial$action_examples/actionHeader.adoc[] + +.OliveTin `config.yaml` +[source,yaml] +.... +actions: + # This will SSH into a server an run the command 'service httpd restart' + - title: Restart httpd on Server 1 + shell: ssh root@server-with-olivetin 'service httpd restart' + icon: ping + timeout: 5 +.... + +*Note about SSH keys*: You should make sure that the user that OliveTin is running as has access to a SSH key. This applies to container images as well. The setup instructions below briefly explain how to generate a SSH key and make it accessible to OliveTin which is running inside a container. + +[#ssh-container] + SSH from inside a Container - setup instructions + +This is a two step process; + +* [red]#<># Give OliveTin a SSH key +* [red]#<># Setup actions that use SSH with this key + +Visually, this is what it looks like - OliveTin is running in the (orange) container, and then can either connect back to _server-with-olivetin_ or _server2_. + +image::../ssh-diagram.png[] + +The steps in detail are below; + +[#ssh-step-1] + [red]#Step 1#: Give OliveTin a SSH key + +Open a terminal window on _server-with-olivetin_. + +[loweralpha] +. Create the `/opt/OliveTinSshKeys` directory, to create a shared directory for your SSH key file. ++ +[source,bash] +---- +root@server-with-olivetin: mkdir /opt/OliveTinSshKeys +---- ++ +This will later be used as a "volume mount" when you create a docker container. + +. Run `ssh-keygen` to generate a SSH key just for OliveTin. ++ +[source,bash] +---- +root@server-with-olivetin: ssh-keygen +---- +[lowerroman] +.. Enter the file in which to save the key: `/opt/OliveTinSshKeys/id_rsa` +.. Enter passphrase (empty for no passphrase): `` ++ +This will create a passwordless SSH key that OliveTin can use. It is safe as long as nobody steals your SSH key file! OliveTin cannot enter passwords into SSH keys, so you have to leave the password blank. + +. You should get something that looks like this. If you get a "permission denied" error when creating files, try running `chmod 0777 /opt/OliveTinSshKeys` and try again. ++ +[source] +---- +root@server-with-olivetin: ssh-keygen +Generating public/private rsa key pair. +Enter file in which to save the key (/root/.ssh/id_rsa): /opt/OliveTinSshKeys/id_rsa +Enter passphrase (empty for no passphrase): +Enter same passphrase again: +Your identification has been saved in /opt/OliveTinSshKeys/id_rsa +Your public key has been saved in /opt/OliveTinSshKeys/id_rsa.pub +The key fingerprint is: +SHA256:t+vGUn+MTeOtRDpxKanO3Cg63+gvAHslZCe3YVNnfWU root@server-with-olivetin +The key's randomart image is: ++---[RSA 3072]----+ +| .. o. E| +| + * o ...| +| o = + . | +| . . o . . | +| o oS . + + | +| . o ..o *o | +| . . oo.o*.o | +| . +*o+oo= .| +| .=+BX .... | ++----[SHA256------+ +---- ++ +This will create two files, `/opt/OliveTinSshKeys/id_rsa` (your private key) and `/opt/OliveTinSshKeys/id_rsa.pub` (your public key). + +. Copy your public key to every server you want to connect to. ++ +Using the `ssh-copy-id` command is a really quick and safe way to do this. ++ +---- +root@server-with-olivetin: ssh-copy-id -i /opt/OliveTinSshKeys/id_rsa.pub root@localhost +(enter your SSH password) + +root@server2: ssh-copy-id ssh-copy-id -i /opt/OliveTinSshKeys/id_rsa.pub root@server2 +(enter your SSH password) +---- ++ +You will be asked to login with a password for each server. ++ +After you have done that, you will then be able to login with the ssh key instead. Here is a quick way that you can test your SSH key manually; ++ +---- +root@server-with-olivetin: ssh -i /opt/OliveTinSshKeys/id_rsa root@server2 +(you should login without a password) +---- + +. Give the SSH key to the OliveTin container. ++ +The way to do this is via a "volume mount". When you create the container, you use "-v" to specify a volume. ++ +You should mount your SSH keys directory into the OliveTin user's home directory by creating the container like this; ++ +.If you want to create the container from the command line +---- +docker run -v /opt/OliveTinSshKeys/:/home/olivetin/.ssh/ -v /etc/OliveTin/:/config --name OliveTin jamesread/olivetin +---- ++ +.If you are using docker-compose +[source,yaml] +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + volumes: + - "/etc/OliveTin/:/config" + - "/opt/OliveTinSshKeys:/home/olivetin/.ssh" + ports: + - "1337:1337" + restart: unless-stopped +---- + +This also works for things like SSH configuration files, if you want to use them. This is step 1 complete from the diagram above. + +[#ssh-step-2] + [red]#Step 2#: Setup actions that use SSH with this key + +Thankfully, step 2 is very simple! `ssh` commands in your OliveTin `config.yaml` should work without a password!, and allow OliveTin to access services, files, and other stuff outside of the OliveTin container. + +.OliveTin `config.yaml` +[source,shell] +.... +actions: + # This will SSH into a server an run the command 'service httpd restart' + - title: Restart httpd on Server 1 + shell: ssh root@server-with-olivetin 'service httpd restart' + icon: ping + timeout: 5 +.... + diff --git a/docs/modules/ROOT/pages/action_examples/systemd_service.adoc b/docs/modules/ROOT/pages/action_examples/systemd_service.adoc new file mode 100644 index 0000000..4e3e34d --- /dev/null +++ b/docs/modules/ROOT/pages/action_examples/systemd_service.adoc @@ -0,0 +1,24 @@ +[#action-service] += Restart a systemd service + +:systemd: Easy +:container: Not really possible to do. +include::partial$action_examples/actionHeader.adoc[] + +[source,yaml] +.... +actions: + - title: Start httpd + shell: systemctl start httpd + + - title: Stop httpd + shell: systemctl stop httpd + + - title: Restart httpd + shell: systemctl restart httpd + + # https://docs.olivetin.app/action-ssh.html + - title: Restart httpd on server 1 + shell: ssh root@server1 'service httpd restart' +.... + diff --git a/docs/modules/ROOT/pages/action_execution/aftercompletion.adoc b/docs/modules/ROOT/pages/action_execution/aftercompletion.adoc new file mode 100644 index 0000000..0de2767 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/aftercompletion.adoc @@ -0,0 +1,39 @@ +[#after-completion] += Execute after completion + +Sometimes you want to execute another command after the main command executes, this is often the case when you want to check the status of the main command, or if you want to send a notification. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Check date and send notification via apprise + icon: date + shell: date + shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '" +---- + +When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined; + +* `{{ exitCode }}` - The exit code of the previous shell command +* `{{ output }}` - The standard output of the previous shell command +* `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`) +* `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs. + +You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script. + +The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy. + +* https://github.com/caronc/apprise +* https://github.com/caronc/apprise/wiki/config + +[source,yaml] +.`/config/apprise.yaml` +---- +urls: + - tgram://bottoken/ChatID +---- + +== See Also + +* xref:./triggers.adoc[Triggers] - Executing full actions after this one (with separate arguments, etc). diff --git a/docs/modules/ROOT/pages/action_execution/oncalendar.adoc b/docs/modules/ROOT/pages/action_execution/oncalendar.adoc new file mode 100644 index 0000000..416382b --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/oncalendar.adoc @@ -0,0 +1,48 @@ +[#exec-on-calendar] += Execute on calendar file + +NOTE: The feature is currently experimental. + +Sometimes you want to schedule an action to run at a specific date and time, like at 2024-02-07 at 15:30. This is technically called "an instant", and OliveTin can watch a file that contains a list of instants for new additions. + +[source,yaml] +.`start-server-calendar.yaml` +---- +- 2024-03-08T20:11:45+00:00 +- 2024-03-08T20:12:30+00:00 +---- + +OliveTin will watch this file, and also load it on startup. If an instant is seen that is in the past, it is just ignored. If it is in the future then it is scheduled. + +This is how you setup an action to use the calendar file: + +[source,yaml] +.`config.yaml` +---- +actions: + - title: start server + shell: echo "Starting Server!" + execOnCalendarFile: start-server-calendar.yaml +---- + +You will often want an easy way to schedule actions from the web interface as well, you can do this by creating a separate schedule action that adds an instant to the calendar file. + +You can use an argument with `type: datetime` to create a date selector in the web interface, to easily select dates to be added to the calendar file. You will have to add hardcoded timezone to suit your needs, you can see below that "+00:00" is being added to "{{ when }}" to create an instant in the UTC timezone. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Schedule server + shell: echo '- {{ when }}+00:00' >> start-server-calendar.yaml + arguments: + - name: when + title: When? + type: datetime + + - title: start server + shell: echo "Starting Server!" + execOnCalendarFile: start-server-calendar.yaml +---- + + diff --git a/docs/modules/ROOT/pages/action_execution/oncron.adoc b/docs/modules/ROOT/pages/action_execution/oncron.adoc new file mode 100644 index 0000000..e602680 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/oncron.adoc @@ -0,0 +1,62 @@ +[#exec-cron] += Execute on schedule (cron) + +OliveTin can execute actions on a schedule, and uses a cron format for configuration. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Say hello + shell: echo "Hello!" + execOnCron: + - "@hourly" + + - title: Say goodbye + shell: echo "Say Goodbye" + execOnCron: + - "*/5 * * * *" # Every 5 minutes +---- + +This is a fantastic website: https://cron.help/ + +== Support for seconds in cron + +The default cron format for OliveTin supports the Unix/Linux format - 5 fields, with no support for seconds. This is by far the most popular format that most people are used to. + +If you need per-second resolution for your actions, this can be enabled in your config - meaning that your cronlines will support 6 columns. The first "new" column is seconds. For example, to execute `date` every 5 seconds; + +[source,yaml] +.`config.yaml` +---- +cronSupportForSeconds: true + +actions: + title: Execute every 5 seconds + shell: date + execOnCron: + - "*/5 * * * * *" +---- + + +== Cron and ACLs + +If you have enabled ACL, cron tasks are run as the user `cron`, which means that your ACL needs to allow the cron user to execute the action. This is one possibilty: + +[source,yaml] +.`config.yaml` +---- +accessControlLists: + - name: "cron" + matchUsernames: + - cron + permissions: + exec: true +actions: + - title: Say hello + shell: echo "Hello!" + execOnCron: + - "@hourly" + acls: + - "cron" +---- diff --git a/docs/modules/ROOT/pages/action_execution/ondemand.adoc b/docs/modules/ROOT/pages/action_execution/ondemand.adoc new file mode 100644 index 0000000..56b09fa --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/ondemand.adoc @@ -0,0 +1,69 @@ +[#exec-on-click] +[#onclick] += Execute on click + +OliveTin has several options to control what happens when an action button is clicked and the execution starts. This can be controlled on a per-action basis using the `onclick` configuration option. + +You can also set the default for OliveTin using the `defaultOnClick` configuration option. + +NOTE: The older names `popupOnStart` and `defaultPopupOnStart` do exactly the same thing. OliveTin copies legacy values into `onclick` / `defaultOnClick` during config load. New configurations should use `onclick` and `defaultOnClick`. + +== Big Flashy Buttons (default) + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Ping the Internet + onclick: default +---- + +This will also be the option that is used if no other values match. + +image::../flashyButton.png[] + +== Execution Dialog + +When an action uses `execution-dialog`, OliveTin opens the execution results view with the command output, plus the start time, end time, exit code, and duration. + +[NOTE] +The legacy option `execution-dialog-stdout-only` is deprecated in OliveTin 3k. It is still accepted in config files for compatibility, but is treated the same as `execution-dialog`. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Check dmesg logs + onclick: execution-dialog +---- + +.Example of `onclick: execution-dialog` +image::action_customization/execution-dialog/executionDialog.png[] + +== Execution Buttons + +This mode will create a new button for each individual execution. This can be useful for actions that are executed again and again. + +The text of the button (eg, "0s" in the screenshot below), is the time it took to execute the action in seconds. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: date + onclick: execution-button +---- + +image::../executionButtons.png[] + +== Action execution history + +The `history` option opens the action details page for that binding when the execution starts, so you can see past runs and status for the same action. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Long-running job + onclick: history +---- diff --git a/docs/modules/ROOT/pages/action_execution/onfilechanged.adoc b/docs/modules/ROOT/pages/action_execution/onfilechanged.adoc new file mode 100644 index 0000000..943d5b2 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/onfilechanged.adoc @@ -0,0 +1,27 @@ +[#exec-file-changed] += Execute on file changed + +You can execute an action when a file is changed in a directory. The argument `filename` is pre-populated for you. + +[source,yaml] +---- +actions: + - title: Print names of new files + shell: "echo Filename: {{ filename }} Filedir: {{ filedir }} Filext: {{ fileext }}" + arguments: + - name: filename + type: unicode_identifier + + - name: filedir + type: unicode_identifier + + - name: fileext + type: unicode_identifier + + execOnFileChangedInDir: + - /home/user/Downloads/ +---- + +include::partial$action_execution/onfileindir_arguments.adoc[] + + diff --git a/docs/modules/ROOT/pages/action_execution/onfilecreated.adoc b/docs/modules/ROOT/pages/action_execution/onfilecreated.adoc new file mode 100644 index 0000000..8b23dd3 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/onfilecreated.adoc @@ -0,0 +1,26 @@ +[#exec-file-created] += Execute on file created + +You can execute an action when a file is created in a directory. The argument `filename` is pre-populated for you. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Print names of new files + shell: echo {{ filename }} + arguments: + - name: filename + type: unicode_identifier + - name: filedir + type: unicode_identifier + - name: fileext + type: unicode_identifier + + execOnFileCreatedInDir: + - /home/user/Downloads/ +---- + +include::partial$action_execution/onfileindir_arguments.adoc[] + + diff --git a/docs/modules/ROOT/pages/action_execution/onstartup.adoc b/docs/modules/ROOT/pages/action_execution/onstartup.adoc new file mode 100644 index 0000000..3a58e38 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/onstartup.adoc @@ -0,0 +1,32 @@ +[#exec-startup] += Execute on startup + +OliveTin can execute actions on a startup. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Say hello + shell: echo "Hello!" + execOnStartup: true +---- + +[#dnf-startup] +== Example: Install additional commands into OliveTin + +This functionality to execute actions on startup is a very easy way to install additional commands in OliveTin, however it requires running OliveTin as a root user to be able to use `microdnf`; + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Install dnsmasq + shell: microdnf install bind-utils + execOnStartup: true +---- + +A more secure method than running DNF as root, is a manual command the temporarily runs as root. To learn more about how to install additional packages into the container in this more secure way, see xref:reference/containerInstallPackages.adoc[Installing extra container packages]. + + + diff --git a/docs/modules/ROOT/pages/action_execution/onwebhook.adoc b/docs/modules/ROOT/pages/action_execution/onwebhook.adoc new file mode 100644 index 0000000..2fb1505 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/onwebhook.adoc @@ -0,0 +1,320 @@ +[#exec-webhook] += Execute on webhook + +Webhooks allow external services to trigger OliveTin actions by sending HTTP POST requests. This is useful for integrating OliveTin with CI/CD pipelines, monitoring systems, IoT devices, or any service that can send HTTP requests. + +OliveTin provides a dedicated webhook endpoint at `/webhooks` that can receive webhook payloads and match them to configured actions. + +== Basic Configuration + +To configure an action to run on a webhook, add the `execOnWebhook` property to your action: + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Deploy Application + id: deploy + shell: /opt/scripts/deploy.sh + execOnWebhook: + - matchHeaders: + X-Event-Type: deploy +---- + +This action will be triggered when a POST request is sent to `/webhooks` with the header `X-Event-Type: deploy`. + +== Webhook Endpoint + +All webhooks are received at: + +---- +http://your-olivetin-server:1337/webhooks +---- + +or + +---- +http://your-olivetin-server:1337/webhooks/ +---- + +Both paths work identically. All webhook requests must use the HTTP POST method. + +== Matching Webhooks + +OliveTin can match incoming webhooks based on several criteria: + +=== Match by Headers + +Match webhooks based on HTTP header values: + +[source,yaml] +---- +actions: + - title: Process Event + shell: echo "Processing event" + execOnWebhook: + - matchHeaders: + X-Event-Type: my-event + X-Source: my-service +---- + +All specified headers must match for the webhook to trigger the action. + +=== Match by Query Parameters + +Match webhooks based on URL query parameters: + +[source,yaml] +---- +actions: + - title: Process Request + shell: echo "Processing request for {{ service }}" + arguments: + - name: service + type: ascii + execOnWebhook: + - matchQuery: + action: deploy + env: production +---- + +A request to `/webhooks?action=deploy&env=production` would match this action. + +=== Match by JSON Body Path + +Match webhooks based on values in the JSON request body using JSONPath expressions: + +[source,yaml] +---- +actions: + - title: Handle Push Event + shell: echo "Push to {{ branch }}" + arguments: + - name: branch + type: ascii + execOnWebhook: + - matchPath: "$.event_type=push" +---- + +The `matchPath` format is `jsonpath=value`. You can also just specify a JSONPath without a value to match if the path exists: + +[source,yaml] +---- +execOnWebhook: + - matchPath: "$.repository.name" # Matches if this path exists in the JSON +---- + +=== Using Regex for Matching + +Header and query parameter values can use regex patterns by prefixing with `regex:`: + +[source,yaml] +---- +actions: + - title: Handle Multiple Events + shell: echo "Handling event" + execOnWebhook: + - matchHeaders: + X-Event-Type: "regex:^(push|pull_request|release)$" +---- + +=== Combining Match Criteria + +You can combine multiple match criteria. All criteria must match for the webhook to trigger: + +[source,yaml] +---- +actions: + - title: Production Deploy + shell: /opt/scripts/deploy.sh production + execOnWebhook: + - matchHeaders: + X-Event-Type: deploy + matchQuery: + environment: production + matchPath: "$.status=approved" +---- + +== Extracting Arguments from Webhooks + +You can extract values from the webhook payload and pass them as arguments to your action using JSONPath expressions: + +[source,yaml] +---- +actions: + - title: Deploy Version + shell: | + echo "Deploying version {{ version }} to {{ environment }}" + /opt/scripts/deploy.sh "{{ version }}" "{{ environment }}" + arguments: + - name: version + type: ascii + - name: environment + type: ascii + execOnWebhook: + - matchHeaders: + X-Event-Type: deploy + extract: + version: "$.release.tag_name" + environment: "$.target.environment" +---- + +The `extract` map defines which action arguments to populate from the webhook payload. The key is the argument name, and the value is the JSONPath expression to extract the value. + +=== Automatic Webhook Metadata + +OliveTin automatically adds several metadata arguments from each webhook request: + +* `webhook_method` - The HTTP method (always POST for webhooks) +* `webhook_path` - The request URL path +* `webhook_query` - The raw query string +* `webhook_header_` - Each HTTP header (lowercase name) + +For example, to access the `X-Request-Id` header in your action: + +[source,yaml] +---- +actions: + - title: Log Request + shell: echo "Request ID: {{ webhook_header_x-request-id }}" + arguments: + - name: webhook_header_x-request-id + type: ascii + execOnWebhook: + - matchHeaders: + X-Event-Type: log +---- + +== Webhook Authentication + +OliveTin supports several authentication methods to verify webhook requests: + +=== No Authentication + +By default, webhooks have no authentication. Any request matching the criteria will trigger the action: + +[source,yaml] +---- +execOnWebhook: + - authType: none + matchHeaders: + X-Event-Type: my-event +---- + +=== HMAC-SHA256 Signature + +Verify webhooks using HMAC-SHA256 signatures (commonly used by GitHub, GitLab, etc.): + +[source,yaml] +---- +execOnWebhook: + - authType: hmac-sha256 + authHeader: X-Hub-Signature-256 + secret: your-webhook-secret + matchHeaders: + X-Event-Type: push +---- + +The `authHeader` specifies which header contains the signature. The signature should be in the format `sha256=`. + +=== HMAC-SHA1 Signature + +For services using HMAC-SHA1 (legacy GitHub webhooks): + +[source,yaml] +---- +execOnWebhook: + - authType: hmac-sha1 + authHeader: X-Hub-Signature + secret: your-webhook-secret + matchHeaders: + X-Event-Type: push +---- + +=== Bearer Token + +Verify webhooks using a Bearer token in the Authorization header: + +[source,yaml] +---- +execOnWebhook: + - authType: bearer + secret: your-bearer-token + matchHeaders: + X-Event-Type: deploy +---- + +The webhook sender must include `Authorization: Bearer your-bearer-token` in the request. + +=== Basic Authentication + +Verify webhooks using HTTP Basic authentication: + +[source,yaml] +---- +execOnWebhook: + - authType: basic + secret: "username:password" + matchHeaders: + X-Event-Type: deploy +---- + +Or with password only: + +[source,yaml] +---- +execOnWebhook: + - authType: basic + secret: "mypassword" + matchHeaders: + X-Event-Type: deploy +---- + +== Multiple Webhook Triggers + +An action can have multiple webhook configurations. The action will be triggered if any of them match: + +[source,yaml] +---- +actions: + - title: Deploy + shell: /opt/scripts/deploy.sh + execOnWebhook: + - matchHeaders: + X-Event-Type: deploy-manual + - matchHeaders: + X-Event-Type: deploy-auto + matchPath: "$.status=success" +---- + +== Testing Webhooks + +You can test your webhook configuration using `curl`: + +[source,bash] +---- +# Simple webhook with headers +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-Event-Type: deploy" \ + -d '{"version": "1.2.3"}' \ + http://localhost:1337/webhooks + +# Webhook with HMAC-SHA256 authentication +SECRET="your-secret" +PAYLOAD='{"event": "push", "branch": "main"}' +SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2) + +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-Event-Type: push" \ + -H "X-Hub-Signature-256: sha256=$SIGNATURE" \ + -d "$PAYLOAD" \ + http://localhost:1337/webhooks +---- + +== See Also + +* xref:action_execution/onwebhook_github.adoc[GitHub Webhooks] - Specific configuration for GitHub webhook events +* xref:solutions/on-git-push/index.adoc[GitOps Solution] - Running actions on Git push using hooks +* xref:api/start_action.adoc[Start Action API] - Alternative method for triggering actions via API diff --git a/docs/modules/ROOT/pages/action_execution/onwebhook_github.adoc b/docs/modules/ROOT/pages/action_execution/onwebhook_github.adoc new file mode 100644 index 0000000..e4e31c9 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/onwebhook_github.adoc @@ -0,0 +1,385 @@ +[#exec-webhook-github] += GitHub Webhooks + +OliveTin includes built-in templates for GitHub webhooks that simplify configuration. Instead of manually configuring header matching and argument extraction, you can use a template that handles all the common settings for you. + +== Supported GitHub Templates + +OliveTin supports the following GitHub webhook templates: + +* `github-push` - Triggered on push events +* `github-pr` or `github-pull-request` - Triggered on pull request events +* `github-release` - Triggered on release events +* `github-workflow` - Triggered on workflow run events + +== Setting Up GitHub Webhooks + +=== 1. Configure the Webhook in GitHub + +1. Go to your GitHub repository → **Settings** → **Webhooks** → **Add webhook** +2. Set the **Payload URL** to `http://your-olivetin-server:1337/webhooks` +3. Set **Content type** to `application/json` +4. Enter a **Secret** (you'll use this in your OliveTin config) +5. Choose which events to trigger the webhook: + - Select **Just the push event** for push triggers + - Or select **Let me select individual events** for more control +6. Click **Add webhook** + +=== 2. Configure OliveTin + +Use the `template` property to apply GitHub-specific settings: + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Deploy on Push + shell: | + echo "Deploying commit {{ git_commit }} to {{ git_branch }}" + /opt/scripts/deploy.sh "{{ git_branch }}" + arguments: + - name: git_commit + type: ascii + - name: git_branch + type: ascii + execOnWebhook: + - template: github-push + secret: your-github-webhook-secret +---- + +== GitHub Push Template + +The `github-push` template is designed for push events. It automatically: + +* Sets authentication to HMAC-SHA256 with the `X-Hub-Signature-256` header +* Matches the `X-GitHub-Event: push` header +* Extracts common push event data + +=== Extracted Arguments + +The following arguments are automatically extracted and available in your action: + +[cols="1,2,2"] +|=== +|Argument Name |Description |JSONPath + +|`git_repository` +|Full repository name (owner/repo) +|`$.repository.full_name` + +|`git_ref` +|Full git reference (e.g., refs/heads/main) +|`$.ref` + +|`git_commit` +|The HEAD commit SHA +|`$.head_commit.id` + +|`git_branch` +|The branch reference +|`$.ref` + +|`git_message` +|The commit message +|`$.head_commit.message` + +|`git_author` +|The commit author's name +|`$.head_commit.author.name` +|=== + +=== Example: Deploy on Push to Main + +[source,yaml] +---- +actions: + - title: Deploy to Production + shell: | + if [ "{{ git_ref }}" = "refs/heads/main" ]; then + echo "Deploying {{ git_commit }} by {{ git_author }}" + /opt/scripts/deploy.sh production + else + echo "Ignoring push to non-main branch" + fi + arguments: + - name: git_ref + type: ascii + - name: git_commit + type: ascii + - name: git_author + type: ascii + execOnWebhook: + - template: github-push + secret: your-secret +---- + +=== Example: Filter by Branch + +To only trigger on specific branches, add a `matchPath` condition: + +[source,yaml] +---- +actions: + - title: Deploy Staging + shell: /opt/scripts/deploy.sh staging + execOnWebhook: + - template: github-push + secret: your-secret + matchPath: '$.ref="refs/heads/develop"' +---- + +== GitHub Pull Request Template + +The `github-pr` (or `github-pull-request`) template handles pull request events. + +=== Extracted Arguments + +[cols="1,2,2"] +|=== +|Argument Name |Description |JSONPath + +|`pr_number` +|Pull request number +|`$.number` + +|`pr_title` +|Pull request title +|`$.pull_request.title` + +|`pr_author` +|PR author's username +|`$.pull_request.user.login` + +|`pr_action` +|Event action (opened, closed, synchronize, etc.) +|`$.action` + +|`git_repository` +|Full repository name +|`$.repository.full_name` + +|`pr_state` +|PR state (open, closed) +|`$.pull_request.state` + +|`pr_head_sha` +|HEAD commit SHA of the PR branch +|`$.pull_request.head.sha` +|=== + +=== Example: Run Tests on PR + +[source,yaml] +---- +actions: + - title: Run PR Tests + shell: | + echo "Running tests for PR #{{ pr_number }}: {{ pr_title }}" + echo "Action: {{ pr_action }}, Author: {{ pr_author }}" + /opt/scripts/run-tests.sh "{{ pr_head_sha }}" + arguments: + - name: pr_number + type: ascii + - name: pr_title + type: ascii + - name: pr_action + type: ascii + - name: pr_author + type: ascii + - name: pr_head_sha + type: ascii + execOnWebhook: + - template: github-pr + secret: your-secret +---- + +=== Example: Only on PR Open or Synchronize + +[source,yaml] +---- +actions: + - title: CI Build + shell: /opt/scripts/ci-build.sh "{{ pr_head_sha }}" + arguments: + - name: pr_head_sha + type: ascii + execOnWebhook: + - template: github-pr + secret: your-secret + matchPath: '$.action="opened"' + - template: github-pr + secret: your-secret + matchPath: '$.action="synchronize"' +---- + +== GitHub Release Template + +The `github-release` template handles release events. + +=== Extracted Arguments + +[cols="1,2,2"] +|=== +|Argument Name |Description |JSONPath + +|`release_action` +|Release action (published, created, etc.) +|`$.action` + +|`release_tag` +|Release tag name +|`$.release.tag_name` + +|`release_name` +|Release name/title +|`$.release.name` + +|`git_repository` +|Full repository name +|`$.repository.full_name` + +|`release_author` +|Release author's username +|`$.release.author.login` +|=== + +=== Example: Deploy on Release + +[source,yaml] +---- +actions: + - title: Deploy Release + shell: | + echo "Deploying release {{ release_tag }}: {{ release_name }}" + /opt/scripts/deploy-release.sh "{{ release_tag }}" + arguments: + - name: release_tag + type: ascii + - name: release_name + type: ascii + execOnWebhook: + - template: github-release + secret: your-secret + matchPath: '$.action="published"' +---- + +== GitHub Workflow Template + +The `github-workflow` template handles workflow run events, useful for triggering actions when GitHub Actions workflows complete. + +=== Extracted Arguments + +[cols="1,2,2"] +|=== +|Argument Name |Description |JSONPath + +|`workflow_name` +|Name of the workflow +|`$.workflow_run.name` + +|`workflow_status` +|Workflow status +|`$.workflow_run.status` + +|`workflow_conclusion` +|Workflow conclusion (success, failure, etc.) +|`$.workflow_run.conclusion` + +|`git_repository` +|Full repository name +|`$.repository.full_name` + +|`git_commit` +|HEAD commit SHA +|`$.workflow_run.head_sha` + +|`git_branch` +|Branch that triggered the workflow +|`$.workflow_run.head_branch` +|=== + +=== Example: Deploy After CI Success + +[source,yaml] +---- +actions: + - title: Deploy After CI + shell: | + echo "CI workflow '{{ workflow_name }}' completed with {{ workflow_conclusion }}" + if [ "{{ workflow_conclusion }}" = "success" ]; then + /opt/scripts/deploy.sh "{{ git_branch }}" "{{ git_commit }}" + fi + arguments: + - name: workflow_name + type: ascii + - name: workflow_conclusion + type: ascii + - name: git_branch + type: ascii + - name: git_commit + type: ascii + execOnWebhook: + - template: github-workflow + secret: your-secret + matchPath: '$.action="completed"' +---- + +== Customizing Templates + +Templates provide default values, but you can override or extend them: + +[source,yaml] +---- +actions: + - title: Custom Push Handler + shell: echo "Push from {{ custom_field }}" + arguments: + - name: custom_field + type: ascii + - name: git_commit + type: ascii + execOnWebhook: + - template: github-push + secret: your-secret + # Add additional extractions + extract: + custom_field: "$.sender.login" + # Add additional match criteria + matchPath: '$.repository.private=false' +---- + +Custom `extract` values are merged with template defaults, so you can add extra fields without losing the standard ones. + +== Security Considerations + +1. **Always use a secret** - Without a secret, anyone can trigger your webhooks +2. **Use HTTPS** - When exposing OliveTin to the internet, use a reverse proxy with TLS +3. **Limit webhook events** - Only subscribe to the events you actually need in GitHub +4. **Validate in your scripts** - Add additional validation in your shell scripts for sensitive operations + +== Troubleshooting + +=== Webhook Not Triggering + +1. Check the OliveTin logs with `logLevel: DEBUG` for webhook processing details +2. Verify the webhook is being received (check GitHub webhook delivery history) +3. Ensure the secret matches exactly between GitHub and OliveTin config +4. Verify the `template` name is spelled correctly + +=== Signature Verification Failed + +1. Ensure the secret in OliveTin matches the one configured in GitHub +2. Check that you're using the correct template (GitHub uses HMAC-SHA256 by default) +3. Make sure the webhook Content-Type is set to `application/json` + +=== Arguments Not Extracted + +1. Verify the argument names match exactly (case-sensitive) +2. Check that arguments are defined in the action's `arguments` list +3. Use `logLevel: DEBUG` to see extracted values in the logs + +== See Also + +* xref:action_execution/onwebhook.adoc[Webhooks Overview] - General webhook configuration +* xref:solutions/on-git-push/index.adoc[GitOps Solution] - Alternative approach using Git hooks +* https://docs.github.com/en/webhooks[GitHub Webhooks Documentation^] - Official GitHub webhook documentation diff --git a/docs/modules/ROOT/pages/action_execution/shellvsexec.adoc b/docs/modules/ROOT/pages/action_execution/shellvsexec.adoc new file mode 100644 index 0000000..4abbc41 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/shellvsexec.adoc @@ -0,0 +1,44 @@ += Shell vs Exec + +OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts strings, and will wrap that whole command in a shell with "bash -c". Exec uses a syscall directly to execute commands. + +* **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|"). +* **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks. + +Shell can be safe and secure with simple argument types (like ascii_identifier), but some argument types like URL can contain basically any character - /, :, ?, &, etc - which can lead to shell injection vulnerabilities while still being a valid URL. + +OliveTin will try and prevent you from using dangerous characters in shell commands (eg, URL is no longer permitted with Shell). + +The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments). + +[source,yaml] +.Using Shell +---- +actions: + - title: List files + shell: ls -l /some/directory +---- + +[source,yaml] +.Using Exec +---- +actions: + - title: List files + exec: + - ls + - -l + - /some/directory +---- + +When in doubt, prefer `exec` over `shell` for better security. Shell was added in both OliveTin 3k and OliveTin 2k in October 2025. + +== What's Next? + +Now that you understand execution methods, continue building your actions: + +* xref:action_buttons/create_your_first.adoc[Create your first action] - Build a simple action to get started +* xref:args/intro.adoc[Add arguments to actions] - Make actions interactive with user input +* xref:action_execution/oncron.adoc[Schedule actions] - Set up automated execution +* xref:action_execution/onwebhook.adoc[Trigger via webhooks] - Integrate with external systems +* xref:security/concepts.adoc[Configure security] - Secure your actions with authentication and authorization +* xref:action_examples/intro.adoc[Browse examples] - See real-world action configurations diff --git a/docs/modules/ROOT/pages/action_execution/triggers.adoc b/docs/modules/ROOT/pages/action_execution/triggers.adoc new file mode 100644 index 0000000..cf1b144 --- /dev/null +++ b/docs/modules/ROOT/pages/action_execution/triggers.adoc @@ -0,0 +1,25 @@ +[#triggers] += Triggers + +Sometimes you want to trigger another action after the first one completes. This is mostly useful for updating hidden actions that update entity files, without having to run those updates on a cron job every 10 seconds! + +NOTE: OliveTin used to support a single action trigger, but now supports multiple triggers. The field `trigger` was renamed to `triggers` and is now an array of triggers. + +[source,yaml] +---- +entities: + - file: /etc/OliveTin/entities/containers.json + name: container + +actions: + - title: stop {{ container.Names }} + shell: docker stop {{ container.Names}} + entity: container + triggers: + - update containers + + - title: update containers + shell: docker ps -a --format=json > /etc/OliveTin/entities/containers.json + hidden: true +---- + diff --git a/docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc b/docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc new file mode 100644 index 0000000..5fb6de5 --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc @@ -0,0 +1,54 @@ +[#config-envs] += Environment Variables in the Config File + +You can pull configuration values from environment variables like this: + +.`config.yaml` +[source,yaml] +---- +logLevel: ${{ LOG_LEVEL }} + +pageTitle: Olivetin - ${{ DEPLOY_ENV }} + +actions: + ... +---- + +While loading the config file, Olivetin will substitute the value of the named environment variable for the token. +If the variable is unset, Olivetin will use an empty string as the value and log a warning. This syntax works even for +configuration values that aren't strings, as long as the final string value can be converted to the proper type. + +== Notes + +. These variables are read while loading the config file. Changes in the environment won't be reflected until the config +file is reloaded. If you want to read environment variables at execution time in your action's `shell` line, make sure +to use regular shell syntax, i.e., `$FOO` rather than `${{ FOO }}`. See xref:args/env.adoc[environment variables] +for info on using environment variables in your actions. + +[#using-env-in-template-replacements] +== Using .Env in template replacements + +In addition to config-file substitution, OliveTin supports using the process environment inside *action templates* (e.g. `shell`, `shellAfterCompleted`, entity directory titles, and other fields that use Go template syntax). Use `{{ .Env.VAR_NAME }}` to substitute an environment variable at the time the action is executed. + +This is useful when you want a command to depend on the runtime environment (e.g. container or system env) rather than config-load time, or when you need env values in template fields that are not the raw `shell` command (where you could use `$VAR`). + +.Example: use `.Env` in a shell command +[source,yaml] +---- +actions: + - title: Run with deploy env + shell: /opt/deploy.sh --env {{ .Env.DEPLOY_ENV }} --host {{ .Env.HOSTNAME }} +---- + +.Example: use `.Env` in a completion notification +[source,yaml] +---- +actions: + - title: Backup + shell: /opt/backup.sh + shellAfterCompleted: "apprise -t 'Backup on {{ .Env.HOSTNAME }}' -b '{{ output }}'" +---- + +`.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json]. + +This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]). diff --git a/docs/modules/ROOT/pages/advanced_configuration/diagnostics.adoc b/docs/modules/ROOT/pages/advanced_configuration/diagnostics.adoc new file mode 100644 index 0000000..c12d09e --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/diagnostics.adoc @@ -0,0 +1,38 @@ += Diagnostics + +OliveTin provides a built-in diagnostics page that can be used to help check how OliveTin is running and help to troubleshoot issues. It's mainly designed for checking SSH configuration at the moment. + +This is a screenshot of the diagnostics page, which can be accessed by clicking the "Diagnostics" link in the navigation bar: + +image::diagnostics.png[] + +== Disabling Diagnostics + +The diagnostics page is enabled by default, but you can disable it by using the OliveTin xref::security/acl.adoc#_acls_and_policies_global[security policy configuration], using the defaults, or via an ACL. Examples are shown below for each of these methods. + +=== Disable Diagnostics for all users; + +[source, yaml] +---- +logLevel: info + +defaultPolicy: + showDiagnostics: false +---- + +=== Disable Diagnostics expect for admin users + +[source, yaml] +---- +logLevel: info +defaultPolicy: + showDiagnostics: false + +accessControlLists: + - name: admin + matchUsernames: + - alice + - bob + policy: + showDiagnostics: true +---- diff --git a/docs/modules/ROOT/pages/advanced_configuration/intro.adoc b/docs/modules/ROOT/pages/advanced_configuration/intro.adoc new file mode 100644 index 0000000..157ea60 --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/intro.adoc @@ -0,0 +1,32 @@ += Advanced Configuration + +This section covers advanced configuration options that provide additional control and customization for OliveTin. These options are typically used by users who need fine-grained control over logging, diagnostics, networking, UI customization, and other advanced features. + +Topics covered in this section include: + +* Application and action logging configuration +* Diagnostics and troubleshooting tools +* Environment variable configuration +* Network port configuration +* Style modifications for UI customization +* Prometheus metrics integration +* Timezone configuration +* Web UI customization options +* Version display in the footer (show or hide the application version) + +Most users will not need to modify these settings for basic OliveTin usage, but they become important when you need to integrate OliveTin into complex environments or customize its behavior to match specific requirements. + +== What's Next? + +Explore the advanced configuration options: + +* xref:advanced_configuration/logs.adoc[Configure application logging] - Set up OliveTin service log levels +* xref:logs/intro.adoc[Browse action logs] - View execution history in the web interface +* xref:advanced_configuration/diagnostics.adoc[Use diagnostics] - Access troubleshooting and diagnostic tools +* xref:advanced_configuration/webui.adoc[Customize the web UI] - Modify the appearance and behavior of the interface +* xref:advanced_configuration/stylemods.adoc[Apply style mods] - Use style modifications for UI customization +* xref:advanced_configuration/prometheus.adoc[Set up Prometheus] - Configure metrics collection +* xref:advanced_configuration/timezones.adoc[Configure timezones] - Set timezone handling for actions +* xref:advanced_configuration/config_envs.adoc[Use environment variables] - Configure environment variable handling +* xref:reference/network-ports.adoc[Understand network ports] - Learn about OliveTin's port configuration +* xref:reference/version_display.adoc[Version display] - Show or hide the application version in the footer diff --git a/docs/modules/ROOT/pages/advanced_configuration/logs.adoc b/docs/modules/ROOT/pages/advanced_configuration/logs.adoc new file mode 100644 index 0000000..90f3697 --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/logs.adoc @@ -0,0 +1,51 @@ +[#log-levels] += Logging - Application + +[NOTE] +There are two different types of logs in OliveTin - xref:advanced_configuration/logs.adoc[application logs] and xref:logs/actions.adoc[action logs]. This page is about the __application logs__, which are the logs that OliveTin itself generates. The action logs are the logs that are generated by the actions that you run in OliveTin. + +OliveTin supports a few different log levels. The default logLevel is `INFO`. + +You can set a `logLevel` in config.yaml like this; + +.`config.yaml` +[source,yaml] +---- +logLevel: "INFO" + +actions: + .... +---- + +The supported log levels are; + +* `DEBUG` - Every possible log message will be shown. This will use a lot of disk space and is not recommended unless you are a developer / like reading code. +* `ERROR` - OliveTin rarely uses the `ERROR` log level. +* `WARN` - Very few messages, only warnings are shown. +* `INFO` - The defualt log level. + +You can change the `logLevel` while OliveTin is running, and it should update as soon as you save your config.yaml. You will always get a log message like this; + +[source,bash] +---- +INFO Setting log level to warning +---- + +== JSON Log Format + +* OliveTin 2k supports JSON log format from version **2025.10.30**. +* OliveTin 3k supports JSON log format from version **3000.2.2**. + +You can enable JSON log format by setting the `OLIVETIN_LOG_FORMAT` environment variable to `json`. + +[source,bash] +.Example of setting `OLIVETIN_LOG_FORMAT` to `json`. +---- +root@server: ./OliveTin +{"commit":"nocommit","date":"nodate","level":"info","msg":"OliveTin initializing","time":"2025-10-30T10:09:55Z","version":"dev"} +{"level":"debug","msg":"Value of -configdir flag","time":"2025-10-30T10:09:55Z","value":"."} +{"level":"debug","msg":"servicehost nonwin","time":"2025-10-30T10:09:55Z"} +{"level":"info","msg":"Setting log level to info","time":"2025-10-30T10:09:55Z"} +{"level":"info","msg":"OliveTin initialization complete","time":"2025-10-30T10:09:55Z"} +{"configDir":"/home/xconspirisist/sandbox/Development/OliveTin/OliveTin","level":"info","msg":"OliveTin started","time":"2025-10-30T10:09:55Z"} +---- diff --git a/docs/modules/ROOT/pages/advanced_configuration/ports.adoc b/docs/modules/ROOT/pages/advanced_configuration/ports.adoc new file mode 100644 index 0000000..b1d07cd --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/ports.adoc @@ -0,0 +1,6 @@ +[#ports] += Ports + +See xref:reference/network-ports.adoc[the network ports] documentation in the reference section. + + diff --git a/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc b/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc new file mode 100644 index 0000000..7c811e2 --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/prometheus.adoc @@ -0,0 +1,40 @@ +[#prometheus] += Prometheus + +OliveTin supports basic Prometheus metrics, and the project is interested to hear about what more metrics people would find useful, as well! + +To enable Prometheus support; + +.`config.yaml` +[source,yaml] +---- +logLevel: INFO + +prometheus: + enabled: true + defaultGoMetrics: false +---- + +It is required to restart OliveTin after changing the `prometheus` configuration. + +The `defaultGoMetrics` option will enable the default Go metrics, which expose metrics like go_memstats_alloc_bytes, or go_memstats_heap_alloc_bytes, +and generally most people don't need these. + +This will give you metrics available at http://yourserver:1337/metrics. The page should look something like this; + +[source] +---- +# HELP olivetin_actions_requested_count The actions requested count +# TYPE olivetin_actions_requested_count gauge +olivetin_actions_requested_count 0 +# HELP olivetin_config_action_count Then number of actions in the config file +# TYPE olivetin_config_action_count gauge +olivetin_config_action_count 18 +# HELP olivetin_config_reloaded_count The number of times the config has been reloaded +# TYPE olivetin_config_reloaded_count counter +olivetin_config_reloaded_count 1 +# HELP olivetin_sv_count The number entries in the sv map +# TYPE olivetin_sv_count gauge +olivetin_sv_count 49 +---- + diff --git a/docs/modules/ROOT/pages/advanced_configuration/stylemods.adoc b/docs/modules/ROOT/pages/advanced_configuration/stylemods.adoc new file mode 100644 index 0000000..6ac66e6 --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/stylemods.adoc @@ -0,0 +1,26 @@ += Stylemods + +There are several style modifications that some people like to use, which can easily be added to OliveTin. The configuration syntax in your `config.yaml` is very simple, and looks like this with a single style mod; + +[source,yaml] +---- +stylemods: + - sm-side-icons +---- + +You can add as many style mods as you like, but note that some of them may conflict with each other. The style mods are applied in the order they are listed, so if you have a conflict, the last one in the list will take precedence. + +== Available Style Mods + +* `sm-side-icons`: Display action buttons with the icons on the left hand side rather than above the text. +* `sm-imageicons-fullwidth`: Display image icons in full width, rather than the default size. +* `sm-transparent-header`: Make the header background transparent. +* `sm-transparent-footer`: Make the footer background transparent. + +NOTE: The `sm-transparent-header` and `sm-transparent-footer` style mods often fix themes that were designed for OliveTin 2k. + +The list of style mods on this page is maintained as new style modifications are added to OliveTin. + +== Feature history + +* OliveTin `2025.7.29` introduced the ability to use style modifications, or "style mods". diff --git a/docs/modules/ROOT/pages/advanced_configuration/timezones.adoc b/docs/modules/ROOT/pages/advanced_configuration/timezones.adoc new file mode 100644 index 0000000..20aa52f --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/timezones.adoc @@ -0,0 +1,18 @@ +[#timezone] += Timezones + +OliveTin will obviously use the system time just like all other programs, but when running in a container, time works in a slightly unusual way. + +You may be used to using a TZ or TIMEZONE environment variable in your Linux container inages, but this is not a standard that works for all Linux distributions - it's mostly supported by Debain based containers. OliveTin's base container image is fedora-minimal, which deliberately does not include timezone data, to reduce storage space. + +To change the time in the OliveTin container, simply bind-mount the correct zone file; + +.Same as the container host +---- +docker create -v /etc/localtime:/etc/localtime -v /etc/OliveTin:/config --name OliveTin docker.io/jamesread/olivetin +---- + +.Different timezone to the container host +---- +docker create -v /usr/share/zoneinfo/Japan:/etc/localtime -v /etc/OliveTin:/config --name OliveTin docker.io/jamesread/olivetin +---- diff --git a/docs/modules/ROOT/pages/advanced_configuration/webui.adoc b/docs/modules/ROOT/pages/advanced_configuration/webui.adoc new file mode 100644 index 0000000..52215bf --- /dev/null +++ b/docs/modules/ROOT/pages/advanced_configuration/webui.adoc @@ -0,0 +1,185 @@ +[#customize-webui] += Customize the web UI + +The OliveTin web UI is reasonably customizable - parts of the page that you don't need can be hidden when they're not needed. + +== Page Title + +You can customize the page title; + +image::page-title.png[] + +.`config.yaml` +[source,yaml] +---- +pageTitle: My OliveTin Instance +---- + +[#show-nav] +== Navigation - show / hide + +You can choose to hide the navigation elements in OliveTin, to present a simplified user interface. + +.The default user interface with the sidebar shown +image::defaultUiWithNav.png[] + +To have OliveTin hide these buttons, add `showNavigation: false` to your config.yaml; + +.`config.yaml` +[source,yaml] +---- +logLevel: "INFO" +showNavigation: false + +actions: + .... +---- + +.The same user interface, but with the sidebar hidden (`showNavigation: false`) +image::defaultUiHideNav.png[] + +[#show-navigate-on-start-icons] +== Navigate-on-start icons on action buttons + +When enabled (the default), each action button can show a small icon indicating what happens when the action is started: + +* **Popup dialog** — the action opens a popup (e.g. `onclick: execution-dialog`) +* **Action history** — the action opens the action details page (e.g. `onclick: history`) +* **Argument form** — the action opens an argument form on start +* **Run in background** — the action runs without opening a dialog + +Set `showNavigateOnStartIcons: false` in your `config.yaml` to hide these indicator icons for a cleaner look. + +.`config.yaml` +[source,yaml] +---- +showNavigateOnStartIcons: false +---- + +[#section-navgiation-style] +== Section Navigation Style + +`sectionNavigationStyle` - You can choose to have the section navigation buttons displayed as a Sidebar (`sidebar` - default), or along the top (`topbar`). + +=== Sidebar navigation style (default) + +`sectionNavigationStyle: sidebar` looks like this; + +image::sidebar.png[] + +=== Topbar navigation style + +`sectionNavigationStyle: topbar` looks like this; + +image::topbar.png[] + +[#show-version-number] +== Version number in the footer + +You can control whether the installed OliveTin version is shown in the web interface. When enabled (the default), the footer displays text like **OliveTin 2024.06.02**. When disabled, the footer shows only **OliveTin** with no version number. + +This is controlled by the **showVersionNumber** policy (in `defaultPolicy` or per user/group in ACLs). Hiding the version also hides any "new version available" link in the footer and redacts the version in xref:troubleshooting/server-diagnostics.adoc[server diagnostics] output, which can be useful for privacy when sharing reports. + +* xref:reference/version_display.adoc[Version display] — full configuration and policy examples + +[#show-new-versions] +== New version available - show/hide + +You can disable the "new version" information in the footer - the default for `showNewVersions` is `true`; + +.`config.yaml` +[source,yaml] +---- +logLevel: "INFO" +showNewVersions: false +---- + +OliveTin does not check for updates by default. To enable it, see xref:reference/updateChecks.adoc[enable update checking]. + + +[#show-footer] +== Footer visibility - show / hide + +You can disable the entire footer, if you would like a really minimal interface. The default for `showFooter` is `true`. + +.`config.yaml` +[source,yaml] +---- +logLevel: "INFO" +showFooter: false +---- + +This means the <> configuration option will automatically be `false` as well. + +== Additional section navigation links + +You can add custom links to the OliveTin navigation bar. This is useful if you want to link to other OliveTin instances, or other web applications. + +[source,yaml] +---- +additionalNavigationLinks: + - title: Duck Duck Go + url: https://duckduckgo.com + target: _blank +---- + +This will render like this; + +image::additionalNavigationLinks.png[] + +[#custom-js] +== Custom JavaScript + +This is considered an advanced feature, and is not recommended unless you like writing your own code. + +You can add custom JavaScript to OliveTin, which will be executed on every page load. This can be useful for adding custom functionality to the web UI. + +1. The custom javascript should be in a file called `custom.js` and saved in `custom-webui/`, which should be in the same directory as your `config.yaml`. +2. You can put whatever code you like really in your `custom.js` file. +3. Set `enableCustomJs: true` in your `config.yaml` to enable this feature. +4. Restart OliveTin. Note that the custom JavaScript will only be loaded once on startup, so if you are changing the custom JavaScript while OliveTin is running, you will need to restart OliveTin to see the changes. + +If the browser blocks your script or network calls with Content Security Policy errors, see xref:security/content_security_policy.adoc[Content Security Policy headers] for how to adjust or disable the CSP sent by OliveTin. + +== Custom CSS (with a custom theme) + +You can customize OliveTin with themes, but it's also possible to write your on very simple theme that contains just a few CSS rules to change the look and feel of OliveTin. This is very useful if you just want to change the colours of OliveTin, or hide a few elements. + +=== Writing a simple theme with a CSS change + +You'll need to create a new theme, and let's assume our theme name is going to be called `uihack`. OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/` in the same directory as your `config.yaml` file. + +Start by creating a directory called `custom-webui/themes/uihack` relative to the same directory as your `config.yaml` file. In this directory, create a file called `theme.css`. + +[source,yaml] +---- +├── config.yaml +└── custom-webui + └── themes + └── uihack + └── theme.css +---- + +Here's an example of what your `theme.css` should contain; + +```css +body { + background-color: red; +} +``` + +=== Setup OliveTin config to use your theme + +Now you need to tell OliveTin to use your new theme. To do this, set `themeName: uihack` in your OliveTin config.yaml and restart OliveTin. + +```yaml +logLevel: "INFO" +themeName: uihack +``` + +[WARNING] +OliveTin will by default only read theme.css once on startup. If you are intending to change theme.css while OliveTin is running, set `themeCacheDisabled: true` in your config.yaml. This will make OliveTin read theme.css on every request, and is useful for development. + +Restart OliveTin for the theme change to take effect. Beware of the theme cache mentioned above, if you are making changes to the CCS and refreshing the page a few times. + +* xref:reference/reference_themes_for_developers.adoc[More information on theme development] diff --git a/docs/modules/ROOT/pages/api/intro.adoc b/docs/modules/ROOT/pages/api/intro.adoc new file mode 100644 index 0000000..4415e33 --- /dev/null +++ b/docs/modules/ROOT/pages/api/intro.adoc @@ -0,0 +1,27 @@ +[#api] + += API Overview +This section of the documentation is intended for developers, and those who want to hack around with OliveTin and extend it. This page provides a few pointers to get started. + +**Short version**: + +* http://olivetinServer:1337/api on your OliveTin server to get the REST API. +* link:http://docs.olivetin.app/api/swagger/[Swagger] documents the API. + +**Longer version**: The OliveTin API is formally defined using the Protobuf IDL, which generates gRPC stubs, as well as a REST Gateway. + +The REST API gateway is used by the WebUI, and you can use it too by default - it is exposed at "/api" by default. + +The gRPC API only listens on localhost default, but it can be set to listen publicly. See xref:reference/network-ports.adoc[the network ports documentation] for a better description of how the APIs are exposed. Most people do not need to use the gRPC API. + +== Links + +* link:http://docs.olivetin.app/api/swagger/[OliveTin 2k API: Swagger UI] +* link:http://docs.olivetin.app/api/swagger/OliveTin.openapi.json[OliveTin 2k API: OpenAPI JSON Definition] +* link:http://docs.olivetin.app/api/swagger/3k/[OliveTin 3k API: Swagger UI] +* link:http://docs.olivetin.app/api/swagger/3k/OliveTin.openapi.json[OliveTin 3k API: OpenAPI JSON Definition] +* link:https://github.com/OliveTin/OliveTin/blob/main/proto/olivetin/api/v1/olivetin.proto[The OliveTin Protobuf file]. + +Please do talk to the developers on Discord if you'd like help using the API, or you're thinking about building something interesting using the API! + + diff --git a/docs/modules/ROOT/pages/api/login.adoc b/docs/modules/ROOT/pages/api/login.adoc new file mode 100644 index 0000000..1fe09bb --- /dev/null +++ b/docs/modules/ROOT/pages/api/login.adoc @@ -0,0 +1,28 @@ += Local User Login via API + +OliveTin supports serveral different ways to login, and most installations will probably login via reverse proxy, or via local user login. + +To login via local user login, you can use the following API call: `/LocalUserLogin`. This is documented in the API documentation which can be found here; https://docs.olivetin.app/api/ + +The API call is a POST request, and you need to provide a `username` and `password` as a JSON object in the body of the request. Here is an example of how you can login via a cURL request; + +```bash +user@host: curl -X POST "http://olivetin-server:1337/api/LocalUserLogin" -H "accept: application/json" -H "Content-Type: application/json" -d '{"username":"admin","password":"toomanysecrets"}' -v +... +< Set-Cookie: olivetin-sid-local=c6c5b2b3-a58b-4dbc-b070-ed7bdd3f1956; Path=/; Max-Age=31556952; HttpOnly +... +{"success":true} +``` + +You can see from the above example that the header response sets a cookie called "olivetin-sid-local" to a UUID which is your session ID. You must include this cookie with future requests to authenticate yourself. + +You can also that the body of the response is a JSON object with a simple `success` key, which will be `true` if the login was successful, and `false` if it was not. + +== Check login with `WhoAmI` + +You can check your current login status by using the `/WhoAmI` API call. This is a GET request, and you need to provide the `olivetin-sid-local` cookie in the request. Here is an example of how you can check your login status via a cURL request; + +```bash +user@host: curl -X GET http://localhost:1337/api/WhoAmI -H "accept: application/json" -H 'Content-Type: application/json' -b "olivetin-sid-local=cd33aa9c-c613-473e-8581-2b742716ab8e" +{"authenticatedUser":"admin", "usergroup":"", "provider":"local", "acls":[], "sid":""} +``` diff --git a/docs/modules/ROOT/pages/api/method_StartAction.adoc b/docs/modules/ROOT/pages/api/method_StartAction.adoc new file mode 100644 index 0000000..31b7dd7 --- /dev/null +++ b/docs/modules/ROOT/pages/api/method_StartAction.adoc @@ -0,0 +1,35 @@ += API Method: StartAction + +This is the method the OliveTin web UI uses to start actions, and is probably the best method to use if you are writing scripts. + +* **HTTP Method**: `POST` +* **Request Type**: OliveTin request object +* **Response Type**: Execution Tracking ID + +There are several variants of this API call available which might be easier for scripts (or humans) to work with: + +include::partial$api/start_action_methods.adoc[] + +== Example API call: Start an action using `StartAction` + +[source,bash] +.curl +---- +user@host: curl "http://olivetin.webapps.teratan.lan/api/StartAction" --json '{"bindingId": "nuclear_reactor_shutdown"}' +---- + +[source,powershell] +.Powershell +---- +PS C:\Users\xcons> $json = '{"bindingId": "deploy_attack_gnomes"}' +PS C:\Users\xcons> Invoke-RestMethod -Method "Post" -Uri "http://olivetinServer:1337/api/StartAction" -Body $json +---- + +== Example API call: Start an action using `StartAction` with arguments + +[source,bash] +.curl +---- +user:host: curl 'http://olivetin.example.com/api/StartAction' --json '{"bindingId": "Ping_host", "arguments": [{"name": "host", "value": "example.com"},{"name": "count", "value": "1"}]}' +---- + diff --git a/docs/modules/ROOT/pages/api/method_StartActionAndWait.adoc b/docs/modules/ROOT/pages/api/method_StartActionAndWait.adoc new file mode 100644 index 0000000..0dd5ec0 --- /dev/null +++ b/docs/modules/ROOT/pages/api/method_StartActionAndWait.adoc @@ -0,0 +1,12 @@ += API Method: StartActionAndWait + +This method is useful is you are writing a script, and want to wait for the action to finish so that you can check the result, or get the output. + +* **HTTP Method**: `POST` +* **Request Type**: OliveTin request object +* **Response Type**: Log Entry (waits for the action to finish) + +There are several variants of this API call available which might be easier for scripts (or humans) to work with: + + +include::partial$api/start_action_methods.adoc[] diff --git a/docs/modules/ROOT/pages/api/method_StartActionByGet.adoc b/docs/modules/ROOT/pages/api/method_StartActionByGet.adoc new file mode 100644 index 0000000..f0b0320 --- /dev/null +++ b/docs/modules/ROOT/pages/api/method_StartActionByGet.adoc @@ -0,0 +1,29 @@ += API Method: StartActionByGet + +This is the method that allows you to specify the action ID in the URL, and is probably the best to do quick integrations - QR Codes, streamdeck, etc. You cannot pass arguments using this method. + +* **HTTP Method**: `GET` +* **Request Type**: Action ID in the URL +* **Response Type**: Execution Tracking ID + +include::partial$api/start_action_methods.adoc[] + +== Example API call; Start an action by ID in the URL + +.curl +---- +user@host: curl http://olivetin.example.com/api/StartActionByGet/pingGithub +---- + +The corresponding config.yaml would look like this; + +[source,yaml] +---- +actions: + - title: Ping GitHub.com + id: pingGithub + shell: ping github.com -c 1 +---- + +IDs are used by these API calls, as you probably want the interface to display a human-readable title, whereas the API call doesn't want to have spaces or punctuation. + diff --git a/docs/modules/ROOT/pages/api/method_StartActionByGetAndWait.adoc b/docs/modules/ROOT/pages/api/method_StartActionByGetAndWait.adoc new file mode 100644 index 0000000..baf1fe6 --- /dev/null +++ b/docs/modules/ROOT/pages/api/method_StartActionByGetAndWait.adoc @@ -0,0 +1,11 @@ += API Method: StartActionByGetAndWait + +This method is also a very easy way to quickly start an action, but it waits for the action to finish before returning the result. This is useful if you want to get the output of the action or check its result without having to poll for it. + +* **HTTP Method**: `GET` +* **Request Type**: Action ID in the URL +* **Response Type**: Log Entry (waits for the action to finish) + +There are several variants of this API call available which might be easier for scripts (or humans) to work with: + +include::partial$api/start_action_methods.adoc[] diff --git a/docs/modules/ROOT/pages/api/misc.adoc b/docs/modules/ROOT/pages/api/misc.adoc new file mode 100644 index 0000000..feb1542 --- /dev/null +++ b/docs/modules/ROOT/pages/api/misc.adoc @@ -0,0 +1,20 @@ += Misc API calls + +== Example API call: Get the dashboard buttons ("components") + +.curl +---- +user@host: curl http://olivetinServer:1337/api/GetDashboardComponents +---- + +== Example API call: readyz Healthcheck + +This is useful for configuring healthchecks in docker containers, or on Kubernetes. + +.curl +---- +user@host: curl http://olivetinServer:1337/api/readyz +{"status": "ok"} +---- + +The response will always be "status: ok" to indicate that the API is up, or it will timeout. diff --git a/docs/modules/ROOT/pages/api/start_action.adoc b/docs/modules/ROOT/pages/api/start_action.adoc new file mode 100644 index 0000000..3e4624e --- /dev/null +++ b/docs/modules/ROOT/pages/api/start_action.adoc @@ -0,0 +1,105 @@ +[#api-start-action] += Starting Actions from the API + +There are several variants of this API call available which might be easier for scripts (or humans) to work with! + +include::partial$api/start_action_methods.adoc[] + +[#api-request-idurl] +== Request type: Action ID in the URL + +Used by: + +*** xref:api/method_StartActionByGet.adoc[StartActionByGet] +*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait] + +If you are trying to integrate OliveTin with your own scripts or processes, it's probably easiest to start actions by using an ID directly in the URL, <>. + +[#api-request-obj] +== Request type: OliveTin request object + +Used by: + +*** xref:api/method_StartAction.adoc[StartAction] +*** xref:api/method_StartActionAndWait.adoc[StartActionAndWait] + +[source,json] +.OliveTin request object structure +---- +{ + "actionId": "string", + "arguments": [ + { + "name": "string", + "value": "string" + } + ], + "uniqueTrackingId": "string" +} +---- + +To find your Action ID, and understand how Action IDs work, see the xref:action_customization/ids.adoc[Action ID] documentation + +If you need more control over the execution, then the only other option is to submit a `OliveTin reqjest object`, which is just a very simple JSON structure like this; + +[source,json] +---- +{ + "actionId": "Generate cryptocurrency", + "arguments": [], + "uniqueTrackingId": "my-tracking-id", +} +---- + +More detailed examples can be seen below. + +[#api-response-trackingid] +== Response type: Execution Tracking ID + +Used by: + +*** xref:api/method_StartAction.adoc[StartAction] +*** xref:api/method_StartActionByGet.adoc[StartActionByGet] + +.Example Execution Tracking ID response +[source,json] +---- +{"executionTrackingId":"5bb4860c-bbd0-4bc9-a7d6-42240551500c"} +---- + +[#api-response-logentry] +== Response type: LogEntry + +Used by: + +*** xref:api/method_StartActionAndWait.adoc[StartActionAndWait] +*** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait] + +.Example log entry +[source,json] +---- +{ + "logEntry": { + "datetimeStarted": "2024-02-27 14:14:49", + "actionTitle": "Restart httpd on server1", + "stdout": "", + "stderr": "", + "timedOut": true, + "exitCode": -1, + "user": "", + "userClass": "", + "actionIcon": "🔄", + "tags": [ + + ], + "executionTrackingId": "b04b1e90-d457-4158-b7dc-da9e81f21568", + "datetimeFinished": "2024-02-27 14:14:52", + "actionId": "restart_httpd", + "executionStarted": true, + "executionFinished": true, + "blocked": false + } +} +---- + + diff --git a/docs/modules/ROOT/pages/args/env.adoc b/docs/modules/ROOT/pages/args/env.adoc new file mode 100644 index 0000000..65c7f75 --- /dev/null +++ b/docs/modules/ROOT/pages/args/env.adoc @@ -0,0 +1,95 @@ +[#env-vars] += Environment variables + +All argument names and values are also passed as environment variables as well, which can be very useful when passing several arguments to a script, for example. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Print names of new files + shell: /opt/newfile.py + arguments: + - name: filename + type: unicode_identifier + + - name: filesizebytes + type: unicode_identifier + + - name: fileisdir + type: unicode_identifier + + execOnFileCreatedInDir: + - /home/user/Downloads/ +---- + +This is an example of a python script using the environment variables; + +[source,python] +.`/opt/newfile.py` +---- +#!/usr/bin/env python + +import os + +print(os.environ['OLIVETIN']) +print(os.environ['FILENAME']) +print(os.environ['FILESIZEBYTES']) +print(os.environ['FILEISDIR']) +---- + +[#execution-request-variables] +== Execution Request Variables + +OliveTin injects two execution request variables into every action execution. They are available as template variables (e.g. in `shell`, `shellAfterCompleted`, or other template fields) and as environment variables passed to the process. + +* `ot_username` — The username of the user who started the execution. In templates (version 3k) use `{{ .Arguments.ot_username }}`; in the process environment it is `OT_USERNAME`. For unauthenticated or automated runs this may be `guest`, `cron`, or similar, depending on how the action was triggered. +* `ot_executionTrackingId` — A unique identifier for this execution. In templates (version 3k) use `{{ .Arguments.ot_executionTrackingId }}`; in the process environment it is `OT_EXECUTIONTRACKINGID`. Useful for logging, correlating with the API or execution log, or idempotency in scripts. + +In version 2k, the template syntax was `{{ ot_username }}` and `{{ ot_executionTrackingId }}` (without the `.Arguments.` prefix). Version 3k uses the `.Arguments.` form. + +Example in a shell command (version 3k): + +[source,yaml] +---- +shell: echo "Run by {{ .Arguments.ot_username }} (execution {{ .Arguments.ot_executionTrackingId }})" +---- + +Example in a script using environment variables: + +[source,shell] +---- +#!/bin/sh +echo "Started by $OT_USERNAME with tracking id $OT_EXECUTIONTRACKINGID" +---- + +In xref:action_execution/aftercompletion.adoc[Execute after completion] (`shellAfterCompleted`), the same variables are available as template variables (e.g. `{{ .Arguments.ot_username }}`, `{{ .Arguments.ot_executionTrackingId }}` in version 3k); user-defined argument values are not passed there. + +[#olivetin-env-var] +== The OLIVETIN environment variable + +OliveTin sets the environment variable `OLIVETIN` to `1` for every action it runs. Scripts can check this variable to detect whether they are running inside OliveTin (for example, to adjust logging, skip interactive prompts, or enable OliveTin-specific behavior). + +[source,shell] +.Example: detect OliveTin in a shell script +---- +#!/bin/sh +if [ "$OLIVETIN" = "1" ]; then + echo "Running under OliveTin" +else + echo "Running outside OliveTin" +fi +---- + +== Using process environment in templates + +To use the *process* environment (the environment OliveTin was started with) inside action template fields such as `shell` or `shellAfterCompleted`, use the `.Env` template variable: `{{ .Env.VAR_NAME }}`. This substitutes the value at execution time. See xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements] for details and examples. + +For other template features, including JSON encoding of argument and entity values, see xref:args/templates.adoc[Templates in actions]. + +== Notes + +. Argument names are converted to uppercase for environment variables, `name: filename` becomes `FILENAME`. +. OliveTin sets `OLIVETIN=1` in the process environment for every action; see <> above. +. The execution request variables are exposed as `OT_USERNAME` and `OT_EXECUTIONTRACKINGID` in the process environment; see <> above. +. The environment variables are passed into the execution context which uses a shell (/bin/sh on Linux), so it is also possible to use them with the $ notation in the `shell` line, like this; `shell: echo $FILENAME` for example. diff --git a/docs/modules/ROOT/pages/args/input.adoc b/docs/modules/ROOT/pages/args/input.adoc new file mode 100644 index 0000000..446ad72 --- /dev/null +++ b/docs/modules/ROOT/pages/args/input.adoc @@ -0,0 +1,48 @@ +[#arg-textbox] += Input: Textbox + +Many times you need to customize how an action/shell command is run, with arguments. For example; + +---- +echo "Hello world" +---- + +In the example above, `Hello world` is an argument passed to the `echo` command. OliveTin allows you to add pre-defined, and free-text arguments to commands in this way. Below is the OliveTin version of the `echo` command shown above; + +[source,yaml] +.`config.yaml` +---- +actions: + - title: echo a message + icon: smile + shell: echo {{ message }} + arguments: + - name: message + default: Hello World + type: ascii_sentence + +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 +---- + +This will give you a normal button, like this; + +image::args/input/args1.png[] + +However, when you click on it, you'll get a prompt to enter arguments, like this; + +image::args/input/args2.png[] + +You'll see that the type is set to `ascii_sentence`. This applies fairly safe +input validation to arguments, so that only a-z, 0-9, spaces and .'s are allowed. + +When you start the action, and it's finished, go to the "logs" view to view the output of the command we've just run. + +image::args/input/args3.png[] diff --git a/docs/modules/ROOT/pages/args/input_checkbox.adoc b/docs/modules/ROOT/pages/args/input_checkbox.adoc new file mode 100644 index 0000000..09196aa --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_checkbox.adoc @@ -0,0 +1,21 @@ +[#confirmation] += Input: Checkbox/Boolean + +The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions. + +[source,yaml] +---- +actions: + - title: remove files + shell: rm {{ useTheForce }} /tmp/Downloads/ + arguments: + - title: Use rm -rf? + name: useTheForce + type: checkbox + choices: + - title: 1 + value: "-rf" + + - title: 0 + value: "" +---- diff --git a/docs/modules/ROOT/pages/args/input_confirmation.adoc b/docs/modules/ROOT/pages/args/input_confirmation.adoc new file mode 100644 index 0000000..6eb0f93 --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_confirmation.adoc @@ -0,0 +1,21 @@ +[#confirmation] += Input: Confirmation + +The `confirmation` type argument is a special argument type, which simply disables the "Start" button until a checkbox is ticked. This can be useful if you have an action with no other arguments, but you want to prevent accidental button-clicks starting the action. + +[source,yaml] +---- +actions: + - title: Delete old backups + icon: ashtonished + shell: rm -rf /opt/oldBackups/ + arguments: + - type: confirmation + title: Are you sure?! +---- + +image::action-confirmation.png[] + +Notice in the webui the "start" button is disabled. + + diff --git a/docs/modules/ROOT/pages/args/input_datetime.adoc b/docs/modules/ROOT/pages/args/input_datetime.adoc new file mode 100644 index 0000000..3d9b385 --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_datetime.adoc @@ -0,0 +1,30 @@ +[#arg-datetime] += Input: DateTime + +OliveTin supports datetime pickers - note that these do NOT add your timezone, so it up to your scripts / commands to interpret which timezone is being used. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Print your favourite datetime! + shell: echo {{ my_favourite_time }} + arguments: + - type: datetime + title: My Favourite DateTime +---- + +image::arg-datetime.png[] + +== Format & Validation + +[NOTE] +==== +The OliveTin server does try to parse and validate the date on the server side to prevent dangerous input, but there is no validation in the browser, beyond what your browser might do to prevent you from picking an invalid date. + + +**This is safe**, as what really matters is what the server allows to be passed to be executed - and that is checked. +==== + +At the time of writing, it is not yet possible to specify only a date, or only a time, or change the date / time format. + +include::partial$args/reject-null.adoc[] diff --git a/docs/modules/ROOT/pages/args/input_dropdown.adoc b/docs/modules/ROOT/pages/args/input_dropdown.adoc new file mode 100644 index 0000000..2dffe73 --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_dropdown.adoc @@ -0,0 +1,83 @@ +[#arg-dropdowns] += Input: Dropdowns + +Predefined choices are normally the safest type of arguments, because users are limited to only enter values that you specify. + +[source,yaml] +---- +actions: + - title: Print a message + icon: smile + shell: echo "{{ message }}" + arguments: + - name: message + description: The message you want to print out. + choices: + - title: Hello + value: Hello there! + + - title: Goodbye + value: Aww, goodbye. :-( +---- + +Note that when predefined choices are used, the argument type is ignored. + +This is what it looks like in the web interface; + +image::args4.png[] + +Then finally, when you execute this command, it would look something like this (remember that this is just a basic "echo" command). + +image::args-choices-exec.png[] + +In the logs, you can then click on the log entry link to open the results; + +image::args/input/args3.png[] + +[#args-dropdown-entities] +== Using Entities in Dropdowns + +Dropdowns can also be populated with a list of entities, like this; + +[source,yaml] +.`config.yaml` +---- +actions: + - title: restart container + shell: 'docker restart {{ containerToRestart }}' + arguments: + - name: containerToRestart + entity: container + title: 'Select Container' + choices: + - value: '{{ container.Names }}' + title: '{{ container.Names }}' + +entities: + - file: entities/containers.json + name: container +---- + +This is what it looks like in the web interface; + +image::args-choices-entities.png[] + +include::partial$args/reject-null.adoc[] + +== Default values + +Dropdown arguments can also have default values. This is done by adding a `default` key to the argument definition. + +include::partial$config-start.adoc[] +---- +actions: + - title: "Print your favorite movie" + shell: echo '{{ movie }} is amazing' + arguments: + - name: movie + choices: + - value: "Star Wars" + - value: "Star Trek" + - value: "The Matrix" + default: "Star Trek" +---- diff --git a/docs/modules/ROOT/pages/args/input_textarea.adoc b/docs/modules/ROOT/pages/args/input_textarea.adoc new file mode 100644 index 0000000..2e06980 --- /dev/null +++ b/docs/modules/ROOT/pages/args/input_textarea.adoc @@ -0,0 +1,21 @@ +[#arg-textarea] += Input: Textarea + +OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these. + +As the name implies, textareas are raw, and are NOT validated by any regex. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: Save text to file + shell: echo "$CONTENT" > file + arguments: + - type: raw_string_multiline + name: content +---- + +This renders like this; + +image::args-multiline-text.png[] diff --git a/docs/modules/ROOT/pages/args/intro.adoc b/docs/modules/ROOT/pages/args/intro.adoc new file mode 100644 index 0000000..e8495a9 --- /dev/null +++ b/docs/modules/ROOT/pages/args/intro.adoc @@ -0,0 +1,23 @@ +[#args] += Introduction to Arguments + +Actions and commands that OliveTin runs, without arguments, are generally quite safe - only that command can be run, without modifications. However, many users need the flexibility to set options on that command - normally called command line arguments. In OliveTin, arguments are defined in a shell commands like `echo {{ message }}`, with a bit of extra configuration. + +Examples of valid argument names are `{{ personName }}`, `{{ customer_number }}` and `{{ ISBN11_code }}`. + +* a-z (case insensitive) +* _ is allowed +* numbers are allowed (argument names can also start with numbers) +* all other characters are invalid for argument names. + +== What's Next? + +Now that you understand how arguments work, explore the different argument types and features: + +* xref:args/types.adoc[Argument types] - Learn about different input types (text, dropdown, checkbox, etc.) +* xref:args/safety.adoc[Argument safety] - Understand how OliveTin keeps arguments safe +* xref:args/suggestions.adoc[Argument suggestions] - Add dynamic suggestions to help users +* xref:args/regex.adoc[Input validation with regex] - Validate user input with regular expressions +* xref:args/env.adoc[Environment variables] - Use arguments to set environment variables +* xref:args/templates.adoc[Templates] - Use Go templates in actions, including JSON encoding +* xref:action_examples/intro.adoc[See examples] - View real-world examples using arguments diff --git a/docs/modules/ROOT/pages/args/password.adoc b/docs/modules/ROOT/pages/args/password.adoc new file mode 100644 index 0000000..7016531 --- /dev/null +++ b/docs/modules/ROOT/pages/args/password.adoc @@ -0,0 +1,19 @@ += Password + +Sometimes you want to mask the input you pass, and a password field is useful for this. + +[WARNING] +Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: echo a message + icon: smile + shell: echo {{ my_password }} + arguments: + - name: my_password + type: password +---- + diff --git a/docs/modules/ROOT/pages/args/regex.adoc b/docs/modules/ROOT/pages/args/regex.adoc new file mode 100644 index 0000000..b173795 --- /dev/null +++ b/docs/modules/ROOT/pages/args/regex.adoc @@ -0,0 +1,25 @@ +[#args-custom-regex] += Custom regex + +OliveTin version 2024.02.081 and above support custom regex patterns for argument types. Here is an example to validate against any 3 letter word; + +NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin. + +[source,yaml] +.`config.yaml` +---- +actions: + - title: echo a message + icon: smile + shell: echo "{{ message }}" + arguments: + - name: message + type: 'regex:^\w\w\w$' +---- + +The site http://regex101.com is a good place to test your regex patterns. OliveTin checks your regex 2 times; + +. **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing. +. **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing. + +You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine. diff --git a/docs/modules/ROOT/pages/args/safety.adoc b/docs/modules/ROOT/pages/args/safety.adoc new file mode 100644 index 0000000..1260527 --- /dev/null +++ b/docs/modules/ROOT/pages/args/safety.adoc @@ -0,0 +1,12 @@ += Important Safety Warning + +Before you continue, it's important to read through this safety warning. + +OliveTin supports customization of command line arguments, but there is a element of risk. For example, if your command is `echo {{ message }}`, and you allow your users to set `{{ message }}` to the value `"" && rm -rf /` , then you've got real problems. For this reason, OliveTin tries to give you useful ways to restrict what users are allowed to enter - with **argument types**. + +However, here are some important rules to try and follow with argument types; + +* Use the most restrictive argument types when possible - `ascii` and `int`. This will stop users entering argument values that might be used dangerously, but it's not foolproof. For example, if you have a command like `createSnapshot.sh --count {{ snapshotCount }}`, and set `snapshotCount` to `int`, then at least users will only be able to enter integer numbers. However, nothing stops them entering crazy values like 9999. +* Don't give access to actions with arguments to people you don't trust. Please don't ever put your OliveTin install on the public internet! + + diff --git a/docs/modules/ROOT/pages/args/suggestions.adoc b/docs/modules/ROOT/pages/args/suggestions.adoc new file mode 100644 index 0000000..9885d02 --- /dev/null +++ b/docs/modules/ROOT/pages/args/suggestions.adoc @@ -0,0 +1,127 @@ +[#arg-suggestions] += Suggestions + +Argument inputs can also have "suggested" values, which can make it quicker to type commonly used options. The way that these are displayed will vary depending on your browser, as they are implemented as a modern HTML5 browser feature called "datalist". + +Suggestions are configured like this; + +[source,yaml] +.Configuration example of input suggestions +---- +actions: + - title: Restart Docker Container + icon: restart + shell: docker restart {{ container }} + arguments: + - name: container + title: Container name + suggestions: + - plex: + - graefik: + - grafana: + - wifi-controller: WiFi Controller + - firewall-controller: Firewall Controller +---- + +In the examples above, there are 5 suggestions. The first 3 suggestions contain a suggestion with a blank title. The last 2 suggestions contain a human readable title (eg: `wifi-controller` is the suggestion, and `WiFi Controller` is the title). + +NOTE: `suggestions:` is a yaml **map**, not a **list**. If you leave the title empty you must still end the suggestion with a ":". + +== Examples + +.Screenshot of input suggestions with Firefox on Linux. +image::args/suggestions/arg-suggestions-firefox.png[] + +.Screenshot of input suggestions with Chrome on Linux. +image::args/suggestions/arg-suggestions-chrome.png[] + +[#suggestions-browser-key] +== Browser-Stored Suggestions + +In addition to static suggestions defined in your configuration, OliveTin can remember values that users have previously entered and offer them as suggestions for future use. This is enabled using the `suggestionsBrowserKey` property. + +When a user submits an action with a `suggestionsBrowserKey` configured, the entered value is saved in the browser's local storage. The next time the user opens the same form (or any form with the same key), those previously-used values appear as suggestions alongside any static suggestions. + +=== Basic Usage + +[source,yaml] +---- +actions: + - title: SSH to Server + shell: ssh {{ hostname }} + arguments: + - name: hostname + title: Hostname + description: Server to connect to + suggestionsBrowserKey: ssh-hosts +---- + +With this configuration: + +1. The first time a user runs this action and enters `server1.example.com`, that value is saved +2. The next time they open the action, `server1.example.com` appears as a suggestion +3. Each new unique value they enter is added to the suggestions list + +=== Sharing Suggestions Across Arguments + +Multiple arguments can share the same `suggestionsBrowserKey`, allowing suggestions to be reused across different actions or arguments. This is useful when the same type of value is used in multiple places. + +[source,yaml] +---- +actions: + - title: Ping Host + shell: ping -c 4 {{ host }} + arguments: + - name: host + title: Hostname + suggestionsBrowserKey: network-hosts + + - title: SSH to Host + shell: ssh {{ server }} + arguments: + - name: server + title: Server + suggestionsBrowserKey: network-hosts + + - title: Traceroute + shell: traceroute {{ destination }} + arguments: + - name: destination + title: Destination + suggestionsBrowserKey: network-hosts +---- + +In this example, all three actions share the `network-hosts` key. If a user enters `192.168.1.100` in the Ping action, that value will also appear as a suggestion in the SSH and Traceroute actions. + +=== Combining Static and Browser Suggestions + +You can use both static `suggestions` and `suggestionsBrowserKey` together. Both sets of suggestions will be displayed to the user: + +[source,yaml] +---- +actions: + - title: Deploy to Environment + shell: /opt/scripts/deploy.sh {{ environment }} + arguments: + - name: environment + title: Environment + suggestions: + production: Production Server + staging: Staging Server + development: Development Server + suggestionsBrowserKey: deploy-environments +---- + +This gives users quick access to the predefined environments while also remembering any custom environments they've deployed to. + +=== Behavior Notes + +* **Password and sensitive fields**: Values from `password`, `checkbox`, and `confirmation` type arguments are never saved to browser storage +* **Empty values**: Empty or blank values are not saved as suggestions +* **Local storage**: Suggestions are stored in the browser's `localStorage` under keys prefixed with `olivetin-suggestions-` +* **Per-browser**: Since suggestions use browser local storage, they are specific to each browser and device - they don't sync across devices or browsers +* **Clearing suggestions**: Users can clear their saved suggestions by clearing their browser's local storage for the OliveTin site + +== Browser Support + +`datalist` is widely supported now-a-days, but Firefox on Android notably lacks support; https://caniuse.com/datalist . See the upstream bug here; https://bugzilla.mozilla.org/show_bug.cgi?id=1535985 . diff --git a/docs/modules/ROOT/pages/args/templates.adoc b/docs/modules/ROOT/pages/args/templates.adoc new file mode 100644 index 0000000..daeb7ee --- /dev/null +++ b/docs/modules/ROOT/pages/args/templates.adoc @@ -0,0 +1,70 @@ +[#templates] += Templates in actions + +OliveTin uses https://pkg.go.dev/text/template[Go text/template] syntax in action fields such as `shell`, `shellAfterCompleted`, entity directory titles, and `enabledExpression`. Template placeholders are written as `{{ ... }}`. + +In OliveTin 3k, use dotted names for template context variables: + +* `{{ .Arguments.NAME }}` — argument values (see xref:args/env.adoc[Environment variables]) +* `{{ .CurrentEntity.property }}` — entity properties (see xref:entities/intro.adoc[Entities]) +* `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements]) +* `{{ .OliveTin.Build.Version }}` and related build/runtime fields + +In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`). + +[#json-encoding] +== JSON encoding with `Json` + +The `Json` template function encodes a value as a JSON string. Pipe a template value to it when you need structured data in a command — for example, passing argument or entity state to a script or HTTP client that expects JSON. + +[source,yaml] +---- +actions: + - title: curl my knx thing + shell: curl --json '{{ .Arguments | Json }}' https://knx.example.com/v1/group/global_on/write + entity: light + arguments: + - name: value + default: "true" +---- + +After template substitution, `{{ .Arguments | Json }}` becomes a JSON object containing all argument names and values for that execution (including execution-request variables such as `ot_username` and `ot_executionTrackingId`). + +=== Examples + +Encode a single argument value: + +[source,yaml] +---- +shell: echo {{ .Arguments.value | Json }} +---- + +If `value` is `hello`, the substituted command is `echo "hello"`. + +Encode an entity field: + +[source,yaml] +---- +shell: curl -d {{ .CurrentEntity.foo.bar | Json }} +---- + +If `foo.bar` is the string `baz`, the substituted command is `curl -d "baz"`. + +Encode a nested entity object: + +[source,yaml] +---- +shell: curl --json -d {{ .CurrentEntity.payload | Json }} +---- + +If `payload` is `{on: true}`, the substituted command is `curl --json -d {"on":true}`. + +=== Notes + +. `Json` uses Go's `encoding/json` package. Strings, numbers, booleans, objects, and arrays are encoded according to normal JSON rules. +. Argument values in templates are strings (`map[string]string`). A checkbox or boolean argument therefore appears in JSON as a string (for example, `"true"`), not a JSON boolean. +. If the piped value is missing or nil, `Json` produces `null`. +. When embedding JSON in a shell command, quote the substitution if the JSON may contain spaces or shell metacharacters. Prefer single-quoted YAML strings around the template when possible, as shown in the curl example above. +. For HTTP request bodies, pass one JSON-encoded value (or build the JSON structure you need in one template expression). Piping several values with spaces between them does not produce a single valid JSON document. + +See link:https://github.com/OliveTin/OliveTin/issues/829[GitHub issue #829] for the original feature request. diff --git a/docs/modules/ROOT/pages/args/types.adoc b/docs/modules/ROOT/pages/args/types.adoc new file mode 100644 index 0000000..ffdfed0 --- /dev/null +++ b/docs/modules/ROOT/pages/args/types.adoc @@ -0,0 +1,34 @@ +[#arg-types] += Argument types + +A full list of argument types are below; + +.Argument types reference table +[%header,cols="1,0,2"] +|=== +| Type | Rendered as | Allowed values +| (default) | xref:args/input.adoc[Textbox] | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults. +| ascii | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, but no spaces or punctuation +| ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. +| shell_safe_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers. +| ascii_sentence | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, with spaces, `.` and `,`. +| unicode_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc. +| email | xref:args/input.adoc[Textbox] | An email address. +| password | xref:args/password.adoc[Password] | A password, which is hidden when typed. +| very_dangerous_raw_string | xref:args/input.adoc[Textbox] | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely. +| regex:... | xref:args/input.adoc[Textbox] | Version 2024.03.081 and above support custom regex patterns. See xref:args/regex.adoc[Custom regex arguments]. +| int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported. +| url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below. +| confirmation | xref:args/input_confirmation.adoc[Confirmation] | A "hidden" argument that makes the action require a confirmation before launching. +| n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching. +| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like +|=== + +[WARNING] +.Security risk: URL argument type +==== +The `url` argument type does not restrict the URL scheme. Users can enter `file://` (local filesystem) URLs, `ftp://`, or other schemes. If the argument value is passed directly to curl, wget, or similar tools, a malicious or mistaken input could read local files, access internal services, or trigger unwanted network requests. + +If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value. +==== + diff --git a/docs/modules/ROOT/pages/config.adoc b/docs/modules/ROOT/pages/config.adoc new file mode 100644 index 0000000..9003020 --- /dev/null +++ b/docs/modules/ROOT/pages/config.adoc @@ -0,0 +1,135 @@ +[#config] += Configuration + +OliveTin is controlled by a `config.yaml` file. On startup, it looks for this +file in the following locations; + +1. The value specified by the `--configdir` argument, which defaults to the current working directory (`./`) +2. `/config/` - Mostly used for containers +3. `/etc/OliveTin/` - this is the recommended directory on Linux for your `config.yaml`. + +The most simple `config.yaml` would be something like this; + +.The most simple `config.yaml` file. +[source,yaml] +---- +actions: + - title: "Hello world!" + shell: echo 'Hello World!' +---- + +The configuration does not really get more complicated than that. You can of course add more actions, and customize more, but the syntax is otherwise extremely simple. + +For building up from here, look at the following resources; + +* See the xref:action_examples/intro.adoc[action examples] section for extra examples of what OliveTin could be configured to do. + +* See the xref:action_customization/intro.adoc[action customization] documentation to customize how those actions work. + +* See the xref:solutions/intro.adoc[Solutions] documentation for just the essential configuration to achieve popular use cases. + +All configuration options are covered in the solution sections + +[#config-list] +== Core functionality + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `actions` | The list of available actions. | `-` | Live Reloadable, but refreshing the web browser is recommended. | xref:action_examples/intro.adoc[Action examples] +| `entities` | A list of "things" you can attach actions to. | `-` | Live Reloadable, but restart is recommended. | xref:entities/intro.adoc[Entities] +| `dashboards` | A grouping of actions, with optional displays, or actions generated from entities. | `-` | Live Reloadable | xref:dashboards/intro.adoc[Dashboards] +|=== + +== UI Customization + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `pageTitle` | A custom title for the OliveTin page. | `OliveTin` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `showFooter` | Show (or hide) the footer. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `showNewVersions` | Show (or hide) new versions in the footer. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `defaultPolicy.showVersionNumber` | Show (or hide) the application version in the footer. Can be overridden per user/group in ACLs. | `true` | Requires restart | xref:reference/version_display.adoc[Version display] +| `showNavigation` | Show (or hide) the sidebar/topbar section navigation. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `showNavigateOnStartIcons` | Show (or hide) the small icons on action buttons that indicate popup/argument/background behavior on start. | `true` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `sectionNavigationStyle` | The style of the section navigation. `sidebar`, `topbar` | `sidebar` | Live reloadable | xref:advanced_configuration/webui.adoc[Customize the web UI]. +| `defaultOnClick` | The default on-click behavior when an action starts. | `nothing` | Live reloadable | xref:action_execution/ondemand.adoc[Execute on click]. +| `defaultPopupOnStart` | Legacy name for `defaultOnClick`. | - | Live reloadable | xref:action_execution/ondemand.adoc[Execute on click]. +| `defaultIconForActions` | The default icon string for actions (Unicode aliases such as `smile`, `hugeicons:NeutralIcon`, HTML, Iconify snippets, images, etc.). See xref:action_customization/icons.adoc[Icons]. | `hugeicons:CommandLineIcon` | Requires Restart | - +| `defaultIconForDirectories` | The default icon to use for directories. | `directory` | Requires Restart | - +| `defaultIconForBack` | The default icon to use for back (from directories). | `«` | Requires Restart | - +| `enableCustomJs` | Enable custom JavaScript. | `false` | Live Reloadable, but refreshing the web browser is required. | xref:advanced_configuration/webui.adoc[Custom JS]. +| `themeName` | The theme to use. | `` | Restart recommended | xref:reference/reference_themes_for_users.adoc[Themes]. +|=== + +== Security Configuration + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `AuthJwtCookieName` | The name of the cookie to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtAud` | The audience to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtDomain` | The domain to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtCertsURL` | The URL to fetch the public keys from with JWKS | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtClaimUsername` | The claim to use for the username. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtClaimUserGroup` | The claim to use for the usergroup. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtHeader` | The HTTP header to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthJwtPubKeyPath` | The path to the public key to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys] +| `AuthHttpHeaderUsername` | The HTTP header to use for the username. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers] +| `AuthHttpHeaderUserGroup` | The HTTP header to use for the usergroup. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers] +| `AuthLocalUsers` | The list of local users. | `[]` | Requires restart | xref:security/local.adoc[Local Users] +| `AuthLoginUrl` | The URL to redirect to for login. | `` | Requires restart | xref:security/local.adoc[Login URL] +| `AuthRequireGuestsToLogin` | Basically disables all functionality for guests. It sets all default permissions to false. | `false` | Requires restart | xref:security/acl.adoc[Access Control Lists] +| `DefaultPermissions` | The default permissions to use. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists] +| `AccessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists] +| `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers] +| `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers] +|=== + +== Networking Configuration + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `UseSingleHttpFrontend` | Whether or not to start the internal "microproxy" frontend. Disabling this is highly unusual and is only really useful for power users. | true | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +| `ListenAddressSingleHTTPFrontend` | The address to listen on for the internal "microproxy" frontend. | `0.0.0.0:1337` | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +| `ListenAddressWebUI` | The address to listen on for the web UI. | `localhost:1340` | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +| `ListenAddressRestActions` | The address for the API | `localhost:1338` | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +| `ListenAddressGrpcActions` | The address for the gRPC API | `localhost:1339` | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +| `ListenAddressPrometheus` | The address for the Prometheus metrics | `localhost:1341` | Requires Restart | xref:reference/network-ports.adoc[Network Ports], xref:advanced_configuration/prometheus.adoc[Prometheus] +| `ExternalRestAddress` | The address the web browser should use to connect to the API. | `.` | Requires Restart | xref:reference/network-ports.adoc[Network Ports] +|=== + +== Debugging Configuration + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `LogLevel` | The log level to use. `INFO`, `DEBUG`, `WARN` | `INFO` | Requires Restart | - +| `LogDebugOptions` | Enable various debug logs. | `-` | Requires Restart | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting] +| `Insecure*` | Various options to disable security features. | `false` | Restart recommended | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting] +|=== + +== Miscellaneous Configuration + +|=== +| Option | Description | Default | Live Reloadable | Documentation + +| `WebUIDir` | The directory to serve the web UI from. | Calculated at runtime. | Requires Restart | - +| `CronSupportForSeconds` | Whether or not to support seconds in cron expressions. | `false` | Requires Restart | xref:action_execution/oncron.adoc[Cron] +| `SaveLogs` | Whether or not to save logs to disk. | `[]` | Requires Restart | xref:logs/saving.adoc[Save Logs] +| `ServiceLogs` | Windows process log directory (`serviceLogs.directory`). | `%ProgramData%\OliveTin\logs\` on Windows | Requires Restart | xref:install/windows_service.adoc#windows-service-logs[Windows service logs] +| `Prometheus` | Prometheus configuration. | `-` | Requires Restart | xref:advanced_configuration/prometheus.adoc[Prometheus] +|=== + +== What's Next? + +Now that you understand the configuration structure, here are the next steps: + +* xref:action_buttons/create_your_first.adoc[Create your first action] - Start building actions for your use case +* xref:action_examples/intro.adoc[Browse action examples] - Get inspiration from real-world configurations +* xref:args/intro.adoc[Add arguments to actions] - Make actions interactive with user input +* xref:dashboards/intro.adoc[Organize with dashboards] - Create custom views to organize your actions +* xref:entities/intro.adoc[Use entities] - Dynamically generate actions from entity files +* xref:security/concepts.adoc[Configure security] - Set up authentication and authorization +* xref:solutions/intro.adoc[Explore solutions] - Find complete configurations for common scenarios diff --git a/docs/modules/ROOT/pages/dashboards/2-fieldsets.adoc b/docs/modules/ROOT/pages/dashboards/2-fieldsets.adoc new file mode 100644 index 0000000..270aa78 --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/2-fieldsets.adoc @@ -0,0 +1,42 @@ +[#fieldsets] += Fieldsets + +It is possible to group actions together in a "group", which is not a directory, but is called +a "fieldset". This is an example of a fieldset that contains two xref:dashboards/3-folders.adoc[Folders]. + +image::fieldset.png[] + +Fieldsets are defined under a xref:dashboards/intro.adoc[Dashboards] in your config.yaml. + +.`config.yaml` +[source,yaml] +---- +dashboards: + - title: My First Dashboard + contents: + - title: Fieldset 1 + type: fieldset + contents: [] + + - title: Fieldset 2 + type: fieldset + contents: [] +---- + +Fieldsets are also generated for you when you use xref:entities/intro.adoc[Entities]. + +.`config.yaml` +[source,yaml] +---- +dashboards: + - title: My First Dashboard + contents: + - title: Fieldset 1 + type: fieldset + entity: server + contents: + - title: Start {{ server.Name }} + - title: Shutdown {{ server.Name }} +---- + + diff --git a/docs/modules/ROOT/pages/dashboards/3-folders.adoc b/docs/modules/ROOT/pages/dashboards/3-folders.adoc new file mode 100644 index 0000000..b4e964e --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/3-folders.adoc @@ -0,0 +1,52 @@ +[#folders] += Folders (Directories) + +Folders (Directories) are a good way to group up actions in the same way that you would +organize files on your computer into directories. + +image::folders.png[] + +You must first create a dashboard to use a directory, and then you "reference" actions that you +want in that folder based on the action name. Anything without a "contents" property is treated +as an action. + +Let's look at the example below with 4 actions, 2 top level folders and 1 subfolder. + +.`config.yaml` +[source,yaml] +---- +actions: + - title: Action 1 + shell: echo "action1" + + - title: Action 2 + shell: echo "action2" + + - title: Action 3 + shell: echo "action3" + + - title: Action 4 + shell: echo "action4" + +dashboards: + - title: My First Dashboard + contents: + - title: Fieldset 1 + type: fieldset + contents: + - title: Folder 1 + contents: + - title: Action 1 + - title: Action 2 + + - title: Subfolder 2 + contents: + - title: Action 3 + + - title: Folder 2 + contents: + - title: Action 4 + +---- + + diff --git a/docs/modules/ROOT/pages/dashboards/4-displays.adoc b/docs/modules/ROOT/pages/dashboards/4-displays.adoc new file mode 100644 index 0000000..41ce6bc --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/4-displays.adoc @@ -0,0 +1,63 @@ +[#displays] += Displays + +Displays are a way of displaying text, values, variables and similar on a dashboard. + +They are rendered as just a simple box, that shown alongside actions. You can add arbitary HTML to a display, which makes it useful for showing links, etc. + +image::dashboard-display.png[] + +[source,yaml] +.`config.yaml` +---- +dashboards: + # This the second dashboard. + - title: My Containers + contents: + # This is a fieldset, which is a way of dashboard items together actions together. + - title: Container {{ container.Names }} + entity: container + type: fieldset + contents: + # This is a display + - type: display + title: | + {{ container.Names }}

{{ container.State }} + + # These are the actions that we want on the dashboard. + - title: 'Start {{ container.Names }}' + - title: 'Stop {{ container.Names }}' +---- + +== CSS Classes + +You can also add CSS classes to the display, which can be useful for styling. + +[source,yaml] +---- +dashboards: + - title: My Containers + contents: + - title: 'Container {{ container.Names.0 }} ({{ container.Image }})' + entity: container + type: fieldset + contents: + - type: display + cssClass: '{{ container.State }}' + title: | + {{ container.Status }}

{{ container.State }} + - title: 'Start {{ container.Names.0 }}' + - title: 'Stop {{ container.Names.0 }}' + - title: 'Remove {{ container.Names.0 }}' +---- + +You can then use the following CSS to style the display; + +[source,css] +---- +div.display.running { + color: green; +} +---- + + diff --git a/docs/modules/ROOT/pages/dashboards/5-output-views.adoc b/docs/modules/ROOT/pages/dashboards/5-output-views.adoc new file mode 100644 index 0000000..08b8c5f --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/5-output-views.adoc @@ -0,0 +1,34 @@ +[#output-views] += Most recent action output + +This is considered an advanced and experimental feature at the moment. + +The `stdout-most-recent-execution` view is a way to display the most recent output of an action on a dashboard. This is useful for actions that are run on a schedule, or actions that are run on startup. This is a picture of what it looks like: + +image::mre.png[] + +To set this up, here is the configuration you need to add to your `config.yaml` file; + +[source,yaml] +---- +actions: + - title: Get status + id: status_command + shell: date + execOnStartup: true + execOnCron: + - "*/1 * * * *" + +dashboards: + - title: Control Panel + contents: + - title: Status + type: fieldset + contents: + - type: stdout-most-recent-execution + title: status_command +---- + +Note that the output only refreshes with the browser, not when the button is clicked. + +As this is an experimental feature, please look at options for xref:troubleshooting/wheretofindhelp.adoc[support] if you need help getting it to work. diff --git a/docs/modules/ROOT/pages/dashboards/actions.adoc b/docs/modules/ROOT/pages/dashboards/actions.adoc new file mode 100644 index 0000000..24bee86 --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/actions.adoc @@ -0,0 +1,9 @@ += The "actions" section + +To make Olivetin easy to use, it generates a default "Actions" section for you, as many +people don't want the hassle of having to configure dashboards. This is fine, you absolutely +do not need a `dashboards:` section in your `config.yaml` at all if you don't want it. + +However, some people prefer to put every action onto a Dashboard. If you so this, OliveTin +will hide the `Actions` view for you on the sidebar. + diff --git a/docs/modules/ROOT/pages/dashboards/css.adoc b/docs/modules/ROOT/pages/dashboards/css.adoc new file mode 100644 index 0000000..69cfe35 --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/css.adoc @@ -0,0 +1,38 @@ +[#dashboard-css] += Change component style + +You can change the style of any dashboard component by adding a `cssClass` property to the component. This is useful for styling actions, displays, fieldsets and folders. Here is an example of how to add a class to an action; + +[source,yaml] +---- +themeName: my-theme + +actions: + - title: My Action + shell: echo "Hello" + +dashboards: + - title: My Dashboard + contents: + - title: My Fieldset + type: fieldset + contents: + - title: My Action + cssClass: big-button +---- + +You can then create a theme, and add the following CSS to style the action; + +[source,css] +.`custom-webui/themes/my-theme/theme.css` +---- +.big-button { + background-color: red; + color: white; + font-size: 20px; + grid-column: span 2; + grid-row: span 2; +} +---- + + diff --git a/docs/modules/ROOT/pages/dashboards/entity-directories.adoc b/docs/modules/ROOT/pages/dashboards/entity-directories.adoc new file mode 100644 index 0000000..04955df --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/entity-directories.adoc @@ -0,0 +1,35 @@ += Entity Directories + +NOTE: Entity directories were added in 3000.6.0. + +Entity directories are a way of grouping actions together based on a single entity instance. + +For example, if you have a `server` entity, you can create a directory called `servers` and then add all the actions that you want to apply to all servers to that directory. + +In the default config, **More Options** is an **entity directory** because it's parent is a fieldset that is defined with an `entity` property. + +[source,yaml] +---- + - type: fieldset + entity: server + title: 'Server: {{ .CurrentEntity.hostname }}' + contents: + # By default OliveTin will look for an action with a matching title + # and put it on the dashboard. + # + # Fieldsets also support `type: display`, which can display arbitary + # text. This is useful for displaying things like a container's state. + - type: display + title: | + Hostname: {{ server.name }} + IP Address: {{ server.ip }} + + # These are the actions (defined above) that we want on the dashboard. + - title: '{{ server.name }} Wake on Lan' + - title: '{{ server.name }} Power Off' + + - title: More Options + type: directory + contents: + - title: '{{ server.name }} Print server name' +---- \ No newline at end of file diff --git a/docs/modules/ROOT/pages/dashboards/examples.adoc b/docs/modules/ROOT/pages/dashboards/examples.adoc new file mode 100644 index 0000000..76ea7ea --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/examples.adoc @@ -0,0 +1,7 @@ += Example Dashboard usage + +Check out the following examples of dashboards in several complete OliveTin solutions; + +* xref:solutions/container-control-panel/index.adoc[Container Control Panel] +* xref:solutions/systemd-control-panel/index.adoc[Systemd Control Panel] + diff --git a/docs/modules/ROOT/pages/dashboards/faq-display-hyperlinks.adoc b/docs/modules/ROOT/pages/dashboards/faq-display-hyperlinks.adoc new file mode 100644 index 0000000..c6099bc --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/faq-display-hyperlinks.adoc @@ -0,0 +1,43 @@ +[#faq-dashboard-display-hyperlinks] += Hyperlinks in dashboards + +This page explains how to add clickable links on dashboards using xref:dashboards/4-displays.adoc[display components] (`type: display`). + +== How do I add a clickable link on a dashboard? + +Use a `type: display` component and put normal HTML in its `title` field. OliveTin renders `title` as HTML, so use an anchor element, for example: + +[source,html] +---- +Documentation +---- + +== Should I open external links in a new tab? + +For links that leave OliveTin, `target="_blank"` is convenient. Combine it with `rel="noopener noreferrer"` so the new page cannot access your OliveTin tab and referrer details are limited. Example: + +[source,yaml] +---- +contents: + - type: display + title: | + Open docs +---- + +== Can I mix links with entity variables or plain text? + +Yes. `title` can combine literal text, xref:entities/intro.adoc[entity template variables], and HTML (such as `
`, ``, or ``). Use YAML's `|` block scalar when you need several lines. + +== Does Markdown link syntax like `[text](url)` work? + +No. Display `title` is not run through a Markdown parser; it is treated as HTML. Use `...` (or other tags you need) explicitly. + +== What should I avoid when embedding HTML? + +Treat anything you put in `title` as trusted markup only you control; do not paste untrusted strings into `href` or other attributes without strict validation. + +Some deployments use xref:security/content_security_policy.adoc[Content Security Policy] headers, which may block certain schemes or injected scripts—ordinary `https:` links are usually the safest choice. + +== Where else is this documented? + +See xref:dashboards/4-displays.adoc[Displays] for full display configuration (including xref:dashboards/css.adoc[CSS classes]). diff --git a/docs/modules/ROOT/pages/dashboards/inline-actions.adoc b/docs/modules/ROOT/pages/dashboards/inline-actions.adoc new file mode 100644 index 0000000..33f8c38 --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/inline-actions.adoc @@ -0,0 +1,18 @@ += Actions Inline in Dashboards + +NOTE: This feature is available in OliveTin version 3000.7.0 and later. If you are using OliveTin 2k, you can only used xref:dashboards/actions.adoc[Linked Actions in Dashboards]. + +[source,yaml] +---- +dashboards: + - name: Main Dashboard + contents: + - inlineAction: + title: Date + shell: date + icon: date +---- + +== See Also + +* xref:dashboards/actions.adoc[Actions (Linked)]] diff --git a/docs/modules/ROOT/pages/dashboards/intro.adoc b/docs/modules/ROOT/pages/dashboards/intro.adoc new file mode 100644 index 0000000..0aca815 --- /dev/null +++ b/docs/modules/ROOT/pages/dashboards/intro.adoc @@ -0,0 +1,96 @@ +[#dashboards] += Dashboards + +OliveTin generates a default view of actions which is useful for simple OliveTin use cases - this is always called "Actions" and cannot be renamed. The Actions view also does not support entities, fieldsets or folders. + +If you want to start organizing OliveTin actions more effectively, then **Dashboards** are for you! + +One of the biggest reasons to use Dashboards as opposed to just the "Actions" section, is that dashboards allow you to use xref:dashboards/3-folders.adoc[Folders], and dashboards really start to get exciting when you start using xref:entities/intro.adoc[entities] and displays. + +image::dashboards/intro/preview.png[] + +== Dashboards pull actions from the default view + +Dashboards are a way of pulling actions from the default "actions" view, and organizing them into groups - either into folders, or fieldsets. Because dashboards literally do "pull" actions from the default view, you cannot use an action in multiple dashboards - and every action must have a unique title. + +== Example configuration + +[source,yaml] +.`config.yaml` +---- +# Actions MUST be defined in the actions section, not in the dashboards +# section. The dashboards only "link" to actions by their title. +actions: + - title: Ping All Servers + shell: echo "ping all..." + + - title: '{{ server.name }} Wake on Lan' + shell: 'wol {{ server.name }}' + timeout: 10 + entity: server + + - title: '{{ server.name }} Power Off' + shell: 'ssh root@{{ server.name }} "poweroff"' + timeout: 10 + entity: server + + +# Dashboards are a way of taking actions from the default "actions" view, and +# organizing them into groups - either into folders, or fieldsets. +# +# The only way to properly use entities, are to use them with a `fieldset` on +# a dashboard. +dashboards: + # Top level items are dashboards. + - title: My Servers + contents: + # On dashboards, all items need to be in a "fieldset". If you don't + # specify a fieldset, actions will be assigned to a fieldset with a title + # called "default". + - title: All Servers + type: fieldset + contents: + # The contents of a dashboard will try to look for an action with a + # matching title IF the `contents: ` property is empty. + - title: Ping All Servers + + # If you create an item with some "contents:", OliveTin will show that as + # directory. + - title: Hypervisors + contents: + - title: Ping hypervisor1 + - title: Ping hypervisor2 + + # If you specify `type: fieldset` and some `contents`, it will show your + # actions grouped together without a folder. + - type: fieldset + entity: server + title: 'Server: {{ server.hostname }}' + contents: + # By default OliveTin will look for an action with a matching title + # and put it on the dashboard. + # + # Fieldsets also support `type: display`, which can display arbitary + # text. This is useful for displaying things like a container's state. + - type: display + title: | + Hostname: {{ server.name }} + IP Address: {{ server.ip }} + + # These are the actions (defined above) that we want on the dashboard. + - title: '{{ server.name }} Wake on Lan' + - title: '{{ server.name }} Power Off' +---- + +== What's Next? + +Now that you understand dashboards, explore these related features: + +* xref:dashboards/2-fieldsets.adoc[Learn about fieldsets] - Group actions visually on dashboards +* xref:dashboards/3-folders.adoc[Organize with folders] - Create folder structures for better organization +* xref:dashboards/4-displays.adoc[Add displays] - Show information alongside actions +* xref:dashboards/faq-display-hyperlinks.adoc[FAQ: Hyperlinks in displays] - Clickable links in display components +* xref:dashboards/5-output-views.adoc[Configure output views] - Customize how action output is displayed +* xref:entities/intro.adoc[Use entities with dashboards] - Dynamically generate actions from entity files +* xref:dashboards/examples.adoc[View dashboard examples] - See complete dashboard configurations +* xref:dashboards/css.adoc[Customize dashboard styling] - Change the appearance of dashboard components diff --git a/docs/modules/ROOT/pages/entities/examples.adoc b/docs/modules/ROOT/pages/entities/examples.adoc new file mode 100644 index 0000000..093b757 --- /dev/null +++ b/docs/modules/ROOT/pages/entities/examples.adoc @@ -0,0 +1,9 @@ +[#entity-examples] += Example Entity Usage + +Check out the following xref:solutions/intro.adoc[Solutions] which make good use of entities. + +* xref:solutions/container-control-panel/index.adoc[Container Control Panel] +* xref:solutions/systemd-control-panel/index.adoc[Systemd Control Panel] + + diff --git a/docs/modules/ROOT/pages/entities/intro.adoc b/docs/modules/ROOT/pages/entities/intro.adoc new file mode 100644 index 0000000..401a2a2 --- /dev/null +++ b/docs/modules/ROOT/pages/entities/intro.adoc @@ -0,0 +1,35 @@ +[#entities] += Entities + +An entity is something that exists - a "thing", like a VM, or a Container is an entity. OliveTin allows you to then dynamically generate actions based around these entities. + +This is really useful if you want to generate wake on lan or poweroff actions for `server` entities, for example. + +A very popular use case that entities were designed for was for `container` entities - in a similar way you could generate `start`, `stop`, and `restart` container actions. + +Entities are just loaded from files on disk, OliveTin will also watch these files for updates while OliveTin is running, and update entities. + +Entities can have properties defined in those files, and those can be used in your configuration as variables. For example; `container.status`, or `vm.hostname`. + +[source,yaml] +---- +entities: + - file: /etc/OliveTin/containers.json + name: container + + - file: /etc/OliveTin/servers.yaml + name: server +---- + +Entity Actions can only be used on xref:dashboards/intro.adoc[Dashboards]. + +== What's Next? + +Now that you understand entities, here's how to use them effectively: + +* xref:entities/yaml.adoc[Create YAML entity files] - Learn the YAML format for entity files +* xref:entities/json.adoc[Create JSON entity files] - Learn the JSON format for entity files +* xref:entities/examples.adoc[View entity examples] - See complete examples of entity configurations +* xref:dashboards/intro.adoc[Use entities in dashboards] - Combine entities with dashboards for dynamic action generation +* xref:solutions/container-control-panel/index.adoc[Container control panel solution] - See a complete example using container entities +* xref:solutions/systemd-control-panel/index.adoc[Systemd control panel solution] - See a complete example using systemd entities diff --git a/docs/modules/ROOT/pages/entities/json.adoc b/docs/modules/ROOT/pages/entities/json.adoc new file mode 100644 index 0000000..490967f --- /dev/null +++ b/docs/modules/ROOT/pages/entities/json.adoc @@ -0,0 +1,12 @@ +[#entities-json] += JSON entity files + +JSON files are parsed as if each line is a single JSON object. This can be super helpful for getting a list of containers, for example; `docker ps -a --format=json > /etc/OliveTin/containers.json`. + +[source,json] +.`/etc/OliveTin/containers.json` +---- +{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2024-02-08 15:27:42 +0000 GMT","ID":"4bafe6f9f956","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-indexing-container","Networks":"bridge","Ports":"","RunningFor":"13 days ago","Size":"0B","State":"exited","Status":"Exited (128) 13 days ago"} +{"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2023-12-17 20:58:03 +0000 GMT","ID":"d25f37c49c35","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-playback-container","Networks":"bridge","Ports":"","RunningFor":"27 days ago","Size":"0B","State":"exited","Status":"Exited (137) 27 days ago"} +---- + diff --git a/docs/modules/ROOT/pages/entities/yaml.adoc b/docs/modules/ROOT/pages/entities/yaml.adoc new file mode 100644 index 0000000..897b762 --- /dev/null +++ b/docs/modules/ROOT/pages/entities/yaml.adoc @@ -0,0 +1,22 @@ +[#entities-yaml] += YAML entity files + +YAML files are the default expected format, so you can use .yml, .yaml, or even .txt - as long as the file contains a valid yaml LIST, then it will be loaded. + +.`/etc/OliveTin/servers.yaml` +[source,yaml] +---- +- name: server1 + state: started + hostname: server1.example.com + ip: 192.168.0.1 +- name: server2 + state: started + hostname: server2.example.com + ip: 192.168.0.2 +- name: server3 + state: stopped + hostname: server3.example.com + ip: 192.168.0.3 +---- + diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc new file mode 100644 index 0000000..447e47b --- /dev/null +++ b/docs/modules/ROOT/pages/index.adoc @@ -0,0 +1,49 @@ += OliveTin Introduction + +**link:https://www.olivetin.app[OliveTin]** gives **safe** and **simple** access to predefined shell commands from a web interface. + +image:icons/GitHub.png[inline] link:https://github.com/jamesread/OliveTin[OliveTin on GitHub] + +image:icons/Discord.png[inline] link:https://discord.gg/jhYWWpNJ3v[Chat on Discord] + +The link:https://www.olivetin.app[OliveTin Homepage is here]. This site that you are viewing is the documentation for OliveTin. + +''' + +== Use cases + +**Safely** give access to commands, for less technical people; + +* eg: Give your family a button to `podman restart plex` +* eg: Give junior admins a simple web form with dropdowns, to start your custom script. `backupScript.sh --folder {{ customerName }}` +* eg: Enable SSH access to the server for the next 20 mins `firewall-cmd --add-service ssh --timeout 20m` + +**Simplify** complex commands, make them accessible and repeatable; + +* eg: Expose complex commands on touchscreen tablets stuck on walls around your house. `wake-on-lan aa:bb:cc:11:22:33` +* eg: Run long running commands on your servers from your cell phone. `dnf update -y` +* eg: Define complex commands with lots of preset arguments, and turn a few arguments into dropdown select boxes. `docker rm {{ container }} && docker create {{ container }} && docker start {{ container }}` + +== Demo + +video::Ej6NM9rmZtk[youtube,width=100%] + +== Features + +* **Responsive, touch-friendly UI** - great for tablets and mobile +* **Super simple config in YAML** - because if it's not YAML now-a-days, it's not "cloud native" :-) +* **Dark mode** - for those of you that roll that way. +* **Accessible** - passes all the accessibility checks in Firefox, and issues with accessibility are taken seriously. +* **Container** - available for quickly testing and getting it up and running, great for the selfhosted community. +* **Integrate with anything** - OliveTin just runs Linux shell commands, so theoretially you could integrate with a bunch of stuff just by using curl, ping, etc. However, writing your own shell scripts is a great way to extend OliveTin. +* **Lightweight on resources** - uses only a few MB of RAM and barely any CPU. Written in Go, with a web interface written as a modern, responsive, Single Page App that uses the REST/gRPC API. +* **Good amount of unit tests and style checks** - helps potential contributors be consistent, and helps with maintainability. + +You can learn more about OliveTin in the link:https://www.olivetin.app[OliveTin Homepage]. + +== Hint for AI agents + +The OliveTin documentation site supports the llm.txt and llm-full.txt files, available at: + +* link:https://docs.olivetin.app/llm.txt[llm.txt] +* link:https://docs.olivetin.app/llm-full.txt[llm-full.txt] diff --git a/docs/modules/ROOT/pages/install/bsd.adoc b/docs/modules/ROOT/pages/install/bsd.adoc new file mode 100644 index 0000000..d517bdb --- /dev/null +++ b/docs/modules/ROOT/pages/install/bsd.adoc @@ -0,0 +1,7 @@ += BSD + +Using BSD? Good for you! :-) + +binary tar.gz: https://github.com/OliveTin/OliveTin/releases/latest/OliveTin-freebsd-amd64.tar.gz + +include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/install/choose_package.adoc b/docs/modules/ROOT/pages/install/choose_package.adoc new file mode 100644 index 0000000..e64e77f --- /dev/null +++ b/docs/modules/ROOT/pages/install/choose_package.adoc @@ -0,0 +1,46 @@ +[#choose-package] += Which download do I need? + +OliveTin can be run as a <> or a xref:install/container.adoc[container]. If you are not sure which is best for you, read xref:install/container_vs_service.adoc[containers vs services]. + +[#package] +== Packages (run OliveTin as a service) + +This is a table that explains which package/download is best for each environment; + +|=== + | Processor Type | Operating System | Distribution | File on link:https://github.com/OliveTin/OliveTin/releases/latest[latest release page] + +.5+| AMD / Intel -> `amd64` .3+| Linux | Other -> `.tar.gz` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-linux-amd64.tar.gz[`OliveTin-linux-amd64.tar.gz`] xref:install/targz.adoc[installation instructions] + | Red Hat, Fedora, etc -> `.rpm` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.rpm[`OliveTin_linux_amd64.rpm`] xref:install/linux_rpm.adoc[installation instructions] + | Debian, Ubuntu -> `.deb` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.deb[`OliveTin_linux_amd64.deb`] xref:install/linux_deb.adoc[installation instructions] + 2+| Windows | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-windows-amd64.zip[`OliveTin-windows-amd64.zip`] + 2+| macOS,macOS | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-macOS-amd64.tar.gz[`OliveTin-macOS-amd64.tar.gz`] +.3+| 32bit ARM (Raspberry Pi 1, 2, or similar) -> `arm` .3+| Linux | Other -> `.tar.gz` | link:https://github.com/OliveTin/OliveTin/releases/latest[One of the `OliveTin-linux-arm....tar.gz` files] xref:install/targz.adoc[installation instructions] + | Red Hat, Fedora, etc -> `.rpm` | link:https://github.com/OliveTin/OliveTin/releases/latest[One of the `OliveTin_linux_arm.....rpm` files] xref:install/linux_rpm.adoc[installation instructions] + | Debian, Ubuntu -> `.deb` | link:https://github.com/OliveTin/OliveTin/releases/latest[One of the `OliveTin_linux_arm....deb` files] xref:install/linux_deb.adoc[installation instructions] +.4+| 64bit ARM (Apple M1, Raspberry Pi 3, 4, or similar) -> `arm64` + + +**Note**: If you are running 32bit Raspberry Pi OS, choose the 32bit ARM download option instead. + .3+| Linux | Other -> `.tar.gz` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-linux-arm64.tar.gz[`OliveTin-linux-arm64.tar.gz`] xref:install/targz.adoc[installation instructions] + | Red Hat, Fedora, etc -> `.rpm` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_arm64.rpm[`OliveTin_linux_arm64.rpm`] xref:install/linux_rpm.adoc[installation instructions] + | Debian, Ubuntu -> `.deb` ` | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_arm64.deb[`OliveTin_linux_arm64.deb`] xref:install/linux_deb.adoc[installation instructions] + 2+| macOS | link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-macOS-amd64.tar.gz[`OliveTin-macOS-arm64.tar.gz`] xref:install/targz.adoc[installation instructions] +|=== + + +A full list of **packages** can be downloaded from the link:https://github.com/jamesread/OliveTin/releases[GitHub project releases] page. + +[#container-images] +== Container images + +include::partial$install/container.adoc[] + +include::partial$install/container_registries.adoc[] + +The following methods can be used to install the container; + +* xref:install/container.adoc[Installation as a standalone container (podman/docker)] +* xref:install/docker_compose.adoc[Installation with Docker Compose] +* xref:install/helm.adoc[Installation on Kubernetes with Helm] +* xref:install/k8s.adoc[Installation on Kubernetes (manually)] diff --git a/docs/modules/ROOT/pages/install/container.adoc b/docs/modules/ROOT/pages/install/container.adoc new file mode 100644 index 0000000..a61be82 --- /dev/null +++ b/docs/modules/ROOT/pages/install/container.adoc @@ -0,0 +1,28 @@ += Linux Container + +== Repositories + +The OliveTin container images are hosted on both Docker Hub and the GitHub Container Registry. + +The main OliveTin image is available at; + +* **Docker Hub**: `docker.io/jamesread/olivetin` + link:https://hub.docker.com/r/jamesread/olivetin/tags?page=1&ordering=last_updated[View on Docker Hub] +* **GitHub**: `ghcr.io/olivetin/olivetin` + link:https://github.com/OliveTin/OliveTin/pkgs/container/olivetin[View on GitHub] + +== Tags + +* `latest-2k` - This tag will always point to the latest OliveTin 2k version (eg 2025.11.11) +* `latest-3k` - This tag will always point to the latest OliveTin 3k version (eg 3000.2.0) +* `latest` - This tag will always point to the latest OliveTin version (currently 3k) + +Read more about 2k vs 3k here: xref:../upgrade/2k3k.adoc[OliveTin 2k vs OliveTin 3k] + +== Container installation options + +* xref:install/podmandocker.adoc[Docker or Podman] +* xref:install/docker_compose.adoc[Docker Compose] +* xref:install/helm.adoc[Kubernetes with Helm] +* xref:install/k8s.adoc[Kubernetes with Manifests] + diff --git a/docs/modules/ROOT/pages/install/container_vs_service.adoc b/docs/modules/ROOT/pages/install/container_vs_service.adoc new file mode 100644 index 0000000..b251638 --- /dev/null +++ b/docs/modules/ROOT/pages/install/container_vs_service.adoc @@ -0,0 +1,21 @@ +[#install-container-vs-service] += Containers vs Services + +Linux Containers have become an incredibly popular method for running software. OliveTin supports every way of running Linux containers - spanning from homelabs up to enterprise environments. As a container, it can be used xref:install/container.adoc[standalone using Podman/Docker], through to xref:install/docker_compose.adoc[using docker-compose] or even on xref:install/k8s.adoc[Kubernetes]. + +**However**, a lot of OliveTin use cases, such as providing an interface to run scripts, rely on files on their local Linux filesystem - containers, by definition, have their own separate filesystem. It is of course possible to "bind mount" parts of your local filesystem into OliveTin, but sometimes this requires more effort, or introduces subtle problems that are more complicated to solve. If you have a good amount of experience with Linux containers, then this approach is absolutely fine - but if you are new to these types of problems - not seeing files you expect, file permissions errors, etc, then it might just be easier to xref:install/choose_package.adoc[install using a Linux package] instead. + +== Don't overcomplicate your containers - use SSH! + +Sometimes you just want to (or have to) use containers, but also want to use these local resources as well. Instead of bind-mounting lots of stuff into the Linux container and creating a really complicated container definition (which is hard to scale), consider a simple alternative: xref:action_examples/ssh-easy.adoc[SSH with OliveTin]. This allows you to easily SSH back into the container host and keep your container definition really simple. This approach works great and is popular for a lot of users. + +== Which should I choose? + +|=== +| Use Case | Recommended Option + +| You're experienced with Linux containers, and know how to bind mount volumes | xref:install/container.adoc[Linux Container] +| You're new to Linux containers, or not very comfortable with them | xref:install/choose_package.adoc[Linux service], or xref:install/container.adoc[Linux container] with xref:action_examples/ssh-easy.adoc[SSH action] +| Needs to use lots of different file paths on the host filesystem | Run as a systemd service, or bind-mount the filesystem into the container. +| Needs to control processes, like systemd services | Run as a systemd service. +|=== diff --git a/docs/modules/ROOT/pages/install/docker_compose.adoc b/docs/modules/ROOT/pages/install/docker_compose.adoc new file mode 100644 index 0000000..b4a5c52 --- /dev/null +++ b/docs/modules/ROOT/pages/install/docker_compose.adoc @@ -0,0 +1,97 @@ +[#install-compose] += Docker Compose install + +Docker compose is a popular way to define multi-container applications using a +infrastructure as code approach. + +If you personally prefer to use `docker compose`, then here is a sample to get +you started; + +[source,yaml] +.`docker-compose.yml` +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + volumes: + - OliveTin-config:/config # replace host path or volume as needed + ports: + - "1337:1337" + restart: unless-stopped + + +volumes: + OliveTin-config: + external: false +---- + +include::partial$install/post_container.adoc[] + +[#compose-docker-socket] +== Controlling other docker containers from a Docker Compose install of OliveTin + +If you want OliveTin running in a container to control other Docker containers, pass the Docker socket into the service and give the container process membership in the same numeric `docker` group that owns the socket on the host. + +On many Linux installs, Docker Engine creates a `docker` group automatically; see https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user[Manage Docker as a non-root user] in the Docker documentation. + +=== Find the `docker` group GID on the host + +On the Docker host, read the `docker` group numeric ID (third field of the output): + +[source,bash] +---- +getent group docker +---- + +If that command prints nothing, create the group or finish Docker post-install steps first, then retry. + +=== Add the socket mount and `group_add` in Compose + +In `docker-compose.yml`, bind-mount the socket and add `group_add` with that GID (as a string is fine). Replace the example GID with the value from your host: + +[source,yaml] +.`docker-compose.yml` including Docker socket access without running as root +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + volumes: + - /docker/OliveTin:/config # replace host path or volume as needed + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - "992" # <1> +---- +<1> Replace `992` with the GID from `getent group docker` on the machine where Compose runs. The number is not portable between hosts. + +This keeps the default container user while allowing access to `/var/run/docker.sock`, which is usually tighter than running the whole service as `root`. + +See xref:action_examples/containers.adoc[containers] for `docker run`, `--privileged`, and other options if you cannot use a `docker` group on the host. + +== Running the OliveTin container as a different user in Compose + +If you need the service to run as a specific Unix user in Compose for reasons other than Docker socket access, set `user` explicitly, for example: + +[source,yaml] +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + user: "1000:1000" + ... +---- + +For Docker socket access from Compose, prefer <> instead of `user: root`. + + +NOTE: xref:troubleshooting/puid-pgid.adoc[PUID and PGID are not used] by the official OliveTin container image. + +[#docker-compose-traefik] +== Using Traefik with Docker Compose + +Traefik is a popular reverse proxy that seems to be used a lot in people's +Docker compose setups. See the xref:reverse-proxies/traefik.adoc[Traefik + Docker Compose] page for more details. + + diff --git a/docs/modules/ROOT/pages/install/helm.adoc b/docs/modules/ROOT/pages/install/helm.adoc new file mode 100644 index 0000000..8c887ac --- /dev/null +++ b/docs/modules/ROOT/pages/install/helm.adoc @@ -0,0 +1,87 @@ +[#install-helm] += Installation on Kubernetes with Helm + +Helm makes installing OliveTin on Kubernetes very easy, the official chart is hosted on Artifact Hub. + +https://artifacthub.io/packages/search?repo=olivetin[image:https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/olivetin[Artifact Hub]] + +== Requirements + +=== Prerequisites + +* [x] A Kubernetes cluster setup and running +* [x] Kubernetes client installed and authenticated +* [x] An ingress controller configure for web traffic +* [x] Helm installed and authenticated to the cluster + +== Installation + +[source,shell] +---- +user@host: helm repo add olivetin https://olivetin.github.io/OliveTin-HelmChart/ +user@host: helm install olivetin olivetin/olivetin +NAME: olivetin +LAST DEPLOYED: Tue Apr 1 23:19:09 2025 +NAMESPACE: default +STATUS: deployed +REVISION: 1 +TEST SUITE: None +---- + +== Configure + +After a minute or two, check the pod status; + +[source,shell] +---- +user@host: kubectl get pods +NAME READY STATUS RESTARTS AGE +olivetin-578b79766-4b8lg 1/1 Running 0 87s +---- + +Hopefully the pod is not crashlooping or anything like that. If it is, check the logs. + +The helm chart should have created a basic ConfigMap for you; + +[source,shell] +---- +user@host: kubectl describe cm/olivetin-config +Name: olivetin-config +Namespace: default +Labels: app.kubernetes.io/managed-by=Helm +Annotations: meta.helm.sh/release-name: olivetin + meta.helm.sh/release-namespace: default + +Data +==== +config.yaml: +-- +actions: + - title: "Hello world!" + shell: echo 'Hello World!' + +---- + +You should edit this ConfigMap to match your needs for OliveTin. Remember to restart the OliveTin deployment if you want the config changes to be picked up more quickly; + +[source,shell] +---- +user@host: kubectl rollout restart deploy/olivetin +deployment.apps/olivetin restarted +---- + + +include::partial$install/to_config.adoc[] + +== Included templates + +You can view the raw templates here: https://github.com/OliveTin/OliveTin-HelmChart/tree/main/charts/olivetin/templates + +* Deployment +* ConfigMap +* Service +* Ingress (optional) + +== See Also + +* xref:solutions/k8s-control-panel-hosted/index.adoc[Solution: Kubernetes Control Panel (Hosted)] diff --git a/docs/modules/ROOT/pages/install/intro.adoc b/docs/modules/ROOT/pages/install/intro.adoc new file mode 100644 index 0000000..baddb14 --- /dev/null +++ b/docs/modules/ROOT/pages/install/intro.adoc @@ -0,0 +1,42 @@ += Installation guide + +== Preparation + +You will need approximately **10 minutes** to install OliveTin on almost every platform, with root/system administrator access in most cases. + +You probably will need about **10-20 minutes** to understand how OliveTin configuration works, and to be able to write your first action to make it do something useful. + +== Where to find help + +include::partial$support.adoc[] + +== First step: where are you installing? + +* Linux +** xref:install/container_vs_service.adoc[Containers or Service?] +** Linux Service +*** xref:install/linux_fedora.adoc[Fedora Linux] +*** xref:install/linux_alpine.adoc[Alpine Linux] +*** xref:install/linux_manjaro.adoc[Manjaro Linux] +*** xref:install/linux_arch.adoc[Arch Linux] +*** xref:install/linux_rpm.adoc[Generic .rpm based Linux] +*** xref:install/linux_deb.adoc[Generic .deb based Linux] +*** xref:install/targz.adoc[.tar.gz Install (manual)] +** xref:install/container.adoc[Linux Container] +*** xref:install/podmandocker.adoc[Docker or Podman] +*** xref:install/docker_compose.adoc[Docker Compose] +*** xref:install/helm.adoc[Kubernetes with Helm] +*** xref:install/k8s.adoc[Kubernetes with Manifests] +* xref:install/bsd.adoc[FreeBSD] +* xref:install/windows.adoc[Windows] +* xref:install/macos.adoc[MacOS] + +== What's Next? + +After installing OliveTin, follow these steps to get started: + +* xref:config.adoc[Learn about configuration] - Understand how OliveTin's configuration works +* xref:action_buttons/create_your_first.adoc[Create your first action] - Build a simple action to test your installation +* xref:action_examples/intro.adoc[Explore action examples] - See what OliveTin can do with real-world examples +* xref:solutions/intro.adoc[Check out solutions] - Find complete configurations for common use cases +* xref:reverse-proxies/intro.adoc[Set up a reverse proxy] - Configure secure access through a reverse proxy diff --git a/docs/modules/ROOT/pages/install/k8s.adoc b/docs/modules/ROOT/pages/install/k8s.adoc new file mode 100644 index 0000000..f356997 --- /dev/null +++ b/docs/modules/ROOT/pages/install/k8s.adoc @@ -0,0 +1,44 @@ +[#install-k8s] += Kubernetes with Manifest files + +OliveTin works just fine on Kubernetes. The easiest way to deploy it is with a +Kubernetes `ConfigMap`, `Deployment`, `Service` and finally `Ingress`. Like so; + +== ConfigMap +[source,yaml] +---- +include::example$k8s_configmap.yml[] +---- + +The main application config.yml for OliveTin is specified in the `ConfigMap` +above. You will want to edit this later - see the "Configuration" section. + +Next, we need a deployment; + +== Deployment +[source,yaml] +---- +include::example$k8s_deployment.yml[] +---- + +That should deploy OliveTin. + +== Service + +Now that OliveTin is deployed, expose it's port as a service; + +[source] +---- +user@host: kubectl expose deployment/olivetin +---- + +Lastly, create a Ingress rule for for that service; + +== Ingress +[source,yaml] +---- +include::example$k8s_ingress.yml[] +---- + +include::partial$install/to_config.adoc[] + diff --git a/docs/modules/ROOT/pages/install/linux_alpine.adoc b/docs/modules/ROOT/pages/install/linux_alpine.adoc new file mode 100644 index 0000000..39c5057 --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_alpine.adoc @@ -0,0 +1,18 @@ +[#install-alpine] += Alpine Linux + +Alpine Linux is supported by an upstream .apk package, or a manual .tar.gz install, or by xref:install/container.adoc[a container]. + +These instructions on this page are quite basic, because not many people have tried to use OliveTin on Alpine, or other OpenRC distributions. If you can help suggest improvements to these docs, or Alpine Linux support, it would be great to xref:troubleshooting/wheretofindhelp.adoc[hear from you]! + +== Installing the upstream `.apk` + +---- +user@host: wget https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.apk +user@host: apk add --allow-untrusted OliveTin_linux_amd64.apk +---- + +== Installing the `.tar.gz` + +The standard xref:install/targz.adoc[.tar.gz instructions] should work just fine, replacing systemd for the OpenRC file. + diff --git a/docs/modules/ROOT/pages/install/linux_arch.adoc b/docs/modules/ROOT/pages/install/linux_arch.adoc new file mode 100644 index 0000000..d7b5f71 --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_arch.adoc @@ -0,0 +1,23 @@ +[#install-archbtw] += Arch Linux (AUR) + +There are 3 packages available for Arch Linux; + +. link:https://aur.archlinux.org/packages/olivetin[`olivetin` in AUR] - This builds from source, using a release Git tag.This is officially maintained by the authors of the OliveTin project. +. link:https://aur.archlinux.org/packages/olivetin-bin[`olivetin-bin` in AUR] - This re-packages the binaries built by the official binaries. This is not officially maintained by the authors of the OliveTin project, and might be a bit older - but it should work just fine. +. link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.apk[`olivetin` .apk built by the project] - This may be useful to have a package outside of AUR. + +== Installation (AUR) + +Install using `yay`; + +---- +user@host: yay -Syu olivetin +---- + +[WARNING] +One does not simply just do something with Arch without telling someone about it. Therefore, after you successfully install OliveTin on Arch, that you tell at least two people "I'm using OliveTin on Arch, btw". :-) + +include::partial$install/post_systemd.adoc[] + + diff --git a/docs/modules/ROOT/pages/install/linux_deb.adoc b/docs/modules/ROOT/pages/install/linux_deb.adoc new file mode 100644 index 0000000..68aa88d --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_deb.adoc @@ -0,0 +1,20 @@ +[#install-linuxpackage] += Generic .deb based Linux + +Running OliveTin as a systemd service on a Linux machine means it can use any program installed on your machine (you don't have to add programs to a container). This is generally easier to use than a container, but containers can work just fine too with a bit more effort. + +There are .deb packages published for OliveTin on each release page. If you distribution is not linked in this installation guide, and you use a .deb based Linux distribution, this package should work. + +* link:https://github.com/jamesread/OliveTin/releases[downloads page]. + +You can install these packages for .deb like this; + +[source,bash] +.... +user@host: wget https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.deb +user@host: dpkg -i OliveTin_linux_amd64.deb +.... + +include::partial$install/post_systemd.adoc[] + + diff --git a/docs/modules/ROOT/pages/install/linux_fedora.adoc b/docs/modules/ROOT/pages/install/linux_fedora.adoc new file mode 100644 index 0000000..1f9684a --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_fedora.adoc @@ -0,0 +1,27 @@ +[#install-fedora] += Fedora Linux (dnf) + +Fedora is included in the Fedora Project official repositories. For an overview of versions, see the Package Sources page; + +https://src.fedoraproject.org/rpms/OliveTin + +== Installation (project package) + +[bash] +---- +root@host: rpm -U https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.rpm +---- + +== Installation (distribution package) + +WARN: +The package included in the Fedora repositories is currently very old, and is not recommended. Please use the project package instead. + +Install using `dnf` (or yum, on older versions of Fedora); + +---- +user@host: dnf install -y OliveTin +---- + +include::partial$install/post_systemd.adoc[] + diff --git a/docs/modules/ROOT/pages/install/linux_manjaro.adoc b/docs/modules/ROOT/pages/install/linux_manjaro.adoc new file mode 100644 index 0000000..c3e8481 --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_manjaro.adoc @@ -0,0 +1,18 @@ +[#install-manjaro] += Manjaro (pamac/AUR) + +Please see teh xref:install-archbtw[Arch Linux] page for a description of the difference between `olivetin-bin` and `olivetin` in AUR. This page assumes you want to compile from source (`olivetin` package). + +[source,bash] +---- +PATH=$PATH:~/go/bin/ +pamac install buf +pamac install olivetin +sudo mkdir -p /etc/OliveTin/custom-webui/themes/ +sudo mkdir -p /etc/OliveTin/entities/ +---- + + +include::partial$install/post_systemd.adoc[] + + diff --git a/docs/modules/ROOT/pages/install/linux_rpm.adoc b/docs/modules/ROOT/pages/install/linux_rpm.adoc new file mode 100644 index 0000000..4726f34 --- /dev/null +++ b/docs/modules/ROOT/pages/install/linux_rpm.adoc @@ -0,0 +1,18 @@ +[#install-linuxpackage] += Generic .rpm based Linux + +Running OliveTin as a systemd service on a Linux machine means it can use any program installed on your machine (you don't have to add programs to a container). This is generally easier to use than a container, but containers can work just fine too with a bit more effort. + +There are .rpm packages published for OliveTin on each release page. If you distribution is not linked in this installation guide, and you use a .rpm based Linux distribution, this package should work. + +* link:https://github.com/jamesread/OliveTin/releases[downloads page]. + +You can install these packages for .rpm like this; + +[source,bash] +.... +user@host: rpm -U https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.rpm +.... + +include::partial$install/post_systemd.adoc[] + diff --git a/docs/modules/ROOT/pages/install/macos.adoc b/docs/modules/ROOT/pages/install/macos.adoc new file mode 100644 index 0000000..6c35f66 --- /dev/null +++ b/docs/modules/ROOT/pages/install/macos.adoc @@ -0,0 +1,51 @@ += macOS Desktop + +OliveTin runs natively on macOS, on both Apple Silicon (M-series) and Intel Macs. It is a single, self-contained binary - there is no installer and no dependencies to set up. + +If you want OliveTin to run in the background and start automatically, follow the xref:install/macos_service.adoc[install OliveTin as a launchd service] instructions instead. + +== Download + +macOS builds are published on the link:https://github.com/OliveTin/OliveTin/releases/latest[releases page]. Choose the archive that matches your Mac's processor: + +[cols="1,1"] +|=== +| Your Mac | Archive + +| Apple Silicon (M1/M2/M3/M4) +| link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-darwin-arm64.tar.gz[`OliveTin-darwin-arm64.tar.gz`] + +| Intel +| link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-darwin-amd64.tar.gz[`OliveTin-darwin-amd64.tar.gz`] +|=== + +Not sure which you have? Run `uname -m` in Terminal - `arm64` means Apple Silicon, `x86_64` means Intel. + +[NOTE] +If you run the wrong architecture, macOS reports `Bad CPU type in executable`. Download the other archive if you see this. + +== Extract + +Start a terminal, then extract the archive and change into the directory (replace `arm64` with `amd64` on Intel): + +[source,shell] +---- +tar -xzf OliveTin-darwin-arm64.tar.gz +cd OliveTin-darwin-arm64 +---- + +== Remove the Gatekeeper quarantine + +The binary is downloaded from the internet and is not notarized by Apple, so on first run Gatekeeper blocks it with a message like _"OliveTin can't be opened because Apple cannot check it for malicious software."_ + +Clear the quarantine attribute so it will run: + +[source,shell] +---- +xattr -dr com.apple.quarantine ./OliveTin +---- + +[TIP] +Alternatively, the first time only, right-click the binary in Finder and choose *Open*, or approve it under *System Settings -> Privacy & Security*. + +include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/install/macos_service.adoc b/docs/modules/ROOT/pages/install/macos_service.adoc new file mode 100644 index 0000000..4b3134c --- /dev/null +++ b/docs/modules/ROOT/pages/install/macos_service.adoc @@ -0,0 +1,225 @@ += macOS Service (launchd) + +This option installs OliveTin as a launchd service, so it runs in the background and starts automatically. This is the macOS equivalent of running OliveTin as a Linux systemd service or a xref:install/windows_service.adoc[Windows service]. If you just want to run OliveTin as a regular application, follow the xref:install/macos.adoc[macOS install] instructions instead. + +Before continuing, complete the xref:install/macos.adoc[macOS install] steps (download, extract, and clear the Gatekeeper quarantine) and confirm OliveTin starts correctly by running `./OliveTin`. + +== Choose LaunchAgent or LaunchDaemon + +launchd offers two ways to run a background service: + +* *LaunchAgent* - runs as your user and starts when you log in. No root required. Best for a desktop Mac. +* *LaunchDaemon* - runs as root and starts at boot, before any user logs in. Best for a headless, always-on Mac. + +Follow one complete flow below. Both use the same plist structure; only the install locations and `launchctl` domain differ. + +== Service definition + +Create a file named `app.olivetin.olivetin.plist` with the contents below. + +[IMPORTANT] +launchd does *not* expand `~`, so every path in the plist must be absolute. Replace `YOUR_USERNAME` with the output of `whoami` when using the LaunchAgent paths. + +[source,xml] +---- + + + + + Label + app.olivetin.olivetin + + ProgramArguments + + /Users/YOUR_USERNAME/Library/Application Support/OliveTin/OliveTin + -configdir + /Users/YOUR_USERNAME/Library/Application Support/OliveTin + + + WorkingDirectory + /Users/YOUR_USERNAME/Library/Application Support/OliveTin + + KeepAlive + + + RunAtLoad + + + StandardOutPath + /Users/YOUR_USERNAME/Library/Logs/OliveTin/olivetin.log + StandardErrorPath + /Users/YOUR_USERNAME/Library/Logs/OliveTin/olivetin.log + + +---- + +For a LaunchDaemon, use the same keys but substitute the paths shown in the table: + +[cols="1,1,1"] +|=== +| Plist entry | LaunchAgent | LaunchDaemon + +| Binary (`ProgramArguments[0]`) +| `/Users/YOUR_USERNAME/Library/Application Support/OliveTin/OliveTin` +| `/usr/local/bin/OliveTin` + +| `-configdir` and `WorkingDirectory` +| `/Users/YOUR_USERNAME/Library/Application Support/OliveTin` +| `/usr/local/etc/OliveTin` + +| `StandardOutPath` / `StandardErrorPath` +| `/Users/YOUR_USERNAME/Library/Logs/OliveTin/olivetin.log` +| `/usr/local/var/log/olivetin.log` +|=== + +`WorkingDirectory` makes the relative `webui` and `var` folders resolve inside the config directory, `KeepAlive` restarts OliveTin if it exits (like systemd's `Restart=always`), and `RunAtLoad` starts it as soon as the service is loaded. + +[NOTE] +OliveTin looks for `config.yaml` in the directory given by the `-configdir` flag. The plist passes `-configdir` explicitly so the service does not depend on the process working directory alone. + +[NOTE] +`launchctl bootstrap`/`bootout` replace the deprecated `launchctl load`/`unload`. + +== LaunchAgent (per-user) + +=== Install the files + +Run these from the extracted archive directory: + +[source,shell] +---- +# Create the application folder and a place for logs +mkdir -p ~/Library/Application\ Support/OliveTin/var +mkdir -p ~/Library/Logs/OliveTin + +# Copy in the binary, your config, and the bundled web UI +cp OliveTin ~/Library/Application\ Support/OliveTin/ +cp config.yaml ~/Library/Application\ Support/OliveTin/ +cp -R webui ~/Library/Application\ Support/OliveTin/ +---- + +This gives you the following layout, all owned by your user: + +[source] +---- +~/Library/Application Support/OliveTin/ +├── OliveTin # the binary +├── config.yaml # your configuration +├── webui/ # the web interface assets (shipped in the archive) +└── var/ # runtime data OliveTin writes (logs, etc.) + +~/Library/Logs/OliveTin/olivetin.log # service stdout/stderr +---- + +=== Register and start + +[source,shell] +---- +cp app.olivetin.olivetin.plist ~/Library/LaunchAgents/ +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +---- + +=== Verify + +Open http://localhost:1337 in a browser. If the page does not load, check the service log: + +[source,shell] +---- +tail -f ~/Library/Logs/OliveTin/olivetin.log +---- + +=== Stop and disable + +[source,shell] +---- +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +---- + +=== Restart after a change + +After editing `config.yaml` or replacing the binary, restart the service so the change takes effect: + +[source,shell] +---- +launchctl kickstart -k gui/$(id -u)/app.olivetin.olivetin +---- + +If you changed the *plist* itself, `kickstart` is not enough - boot the service out and back in so launchd re-reads it (`bootstrap` errors if the service is still loaded): + +[source,shell] +---- +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +---- + +== LaunchDaemon (system-wide) + +=== Install the files + +Run these from the extracted archive directory: + +[source,shell] +---- +sudo mkdir -p /usr/local/bin /usr/local/etc/OliveTin/var /usr/local/var/log + +sudo cp OliveTin /usr/local/bin/OliveTin +sudo cp config.yaml /usr/local/etc/OliveTin/ +sudo cp -R webui /usr/local/etc/OliveTin/ +---- + +This gives you the following layout: + +[source] +---- +/usr/local/bin/OliveTin +/usr/local/etc/OliveTin/ +├── config.yaml +├── webui/ +└── var/ + +/usr/local/var/log/olivetin.log # service stdout/stderr +---- + +=== Register and start + +[source,shell] +---- +sudo cp app.olivetin.olivetin.plist /Library/LaunchDaemons/ +sudo chown root:wheel /Library/LaunchDaemons/app.olivetin.olivetin.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/app.olivetin.olivetin.plist +---- + +=== Verify + +Open http://localhost:1337 in a browser. If the page does not load, check the service log: + +[source,shell] +---- +tail -f /usr/local/var/log/olivetin.log +---- + +=== Stop and disable + +[source,shell] +---- +sudo launchctl bootout system /Library/LaunchDaemons/app.olivetin.olivetin.plist +---- + +=== Restart after a change + +After editing `config.yaml` or replacing the binary, restart the service so the change takes effect: + +[source,shell] +---- +sudo launchctl kickstart -k system/app.olivetin.olivetin +---- + +If you changed the *plist* itself, `kickstart` is not enough - boot the service out and back in so launchd re-reads it (`bootstrap` errors if the service is still loaded): + +[source,shell] +---- +sudo launchctl bootout system /Library/LaunchDaemons/app.olivetin.olivetin.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/app.olivetin.olivetin.plist +---- + +include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/install/podmandocker.adoc b/docs/modules/ROOT/pages/install/podmandocker.adoc new file mode 100644 index 0000000..010fc1d --- /dev/null +++ b/docs/modules/ROOT/pages/install/podmandocker.adoc @@ -0,0 +1,32 @@ += Docker or Podman + +include::partial$install/container.adoc[] + +include::partial$install/container_registries.adoc[] + +If you prefer to use docker-compose, then follow the xref:install/docker_compose.adoc[docker-compose] installation instructions>>. + +The standard container setup just needs **port 1337** forwarded for web traffic, and a volume **to store the configuration file**. Note that OliveTin containers expect the config to be in `/config/` inside the container, but it doesn't really matter where this directory is mounted from on the host. This documention uses the convention of `/etc/OliveTin` on the host, but `/dockerStuff/OliveTin/` or similar would be fine. + +[source, shell] +.Create the container (but don't start it yet) +.... +user@host: mkdir /etc/OliveTin/ +user@host: # ie: Your config file is /etc/OliveTin/config.yaml on the host machine. We'll create this in the post-installation step. +user@host: docker pull jamesread/olivetin +user@host: docker create --name olivetin -p 1337:1337 -v /etc/OliveTin/:/config:ro docker.io/jamesread/olivetin +.... + +NOTE: The OliveTin container is built using fedora-minimal, which doesn't use customizations that some people may be familiar with from popular projects like LSIO, or debian-based containers. The two top misunderstandings are xref:troubleshooting/puid-pgid.adoc[PUID and PGID are ignored]. Please see the instructions below if you're not familiar with changing users or timezones. + +[#container-user] +== Container user + +OliveTin does not need to be run as a root, and does not need any special capabilities. If you want to change the user that OliveTin runs as, use `--user` when creating the container. OliveTin ignores xref:troubleshooting/puid-pgid.adoc[PUID and PGID]. + +[#container-timezone] +== Container timezone + +To change the xref:advanced_configuration/timezones.adoc[changing the timezone requires a bound-mount] from the host. Olivetin ignores the TZ variable as it is non-standard. + +include::partial$install/post_container.adoc[] diff --git a/docs/modules/ROOT/pages/install/targz.adoc b/docs/modules/ROOT/pages/install/targz.adoc new file mode 100644 index 0000000..28cb2d7 --- /dev/null +++ b/docs/modules/ROOT/pages/install/targz.adoc @@ -0,0 +1,25 @@ +[#install-targz] += .tar.gz Install (manual) + +Installing OliveTin from a .tar.gz file is considered advanced setup, and is provided for users who cannot use the .deb or .rpm packages, or who don't want to use the Linux container. + +== Manual setup (.tar.gz) + +. Copy the `OliveTin` binary to `/usr/local/bin/OliveTin` +.. Make sure it is executable: `chmod +x /usr/local/bin/OliveTin` +. Make a directory for the configuration files: `mkdir -p /etc/OliveTin` +.. Copy the `config.yaml` file to `/etc/OliveTin/` +. Copy the `webui` directory contents to `/var/www/olivetin/` (eg, `/var/www/olivetin/index.html`) +. Copy the `OliveTin.service` file to `/etc/systemd/system/` +. Files in the `var` directory are all considered optional. +.. `var/entities/` contains some example entity files used by the default config.yaml. You can copy these to `/etc/OliveTin/entities/` if you want to use them. +.. `var/helper-actions/` contains some helpers that are mostly useful for containers. These should be copied to somewhere on your path if you want to use them, such as `/usr/local/bin/`. +.. `var/initscript/OliveTin` is provided for init-based systems. You can copy this to `/etc/init.d/OliveTin` and make it executable if you want to use it. +.. `var/manpage/OliveTin.1.gz` contains the manpage for OliveTin. You can copy this to `/usr/share/man/man1/` if you want to use it. +.. `var/marketing` contains some marketing materials used by the repository. You can probably ignore these unless you're writing a blog article or something. +.. `var/openrc/OliveTin` is provided for OpenRC-based systems. You can copy this to `/etc/init.d/OliveTin` and make it executable if you want to use it. +.. `var/tekton` is a directory that contains an experimental Tekton base image builder. You don't need this. + +include::partial$install/post_systemd.adoc[] + + diff --git a/docs/modules/ROOT/pages/install/windows.adoc b/docs/modules/ROOT/pages/install/windows.adoc new file mode 100644 index 0000000..7b2eb31 --- /dev/null +++ b/docs/modules/ROOT/pages/install/windows.adoc @@ -0,0 +1,14 @@ += Windows install + +Yes, OliveTin is supported on Windows, too! + +You can instal install OliveTin as a Windows service, follow the instructions to xref:install/windows_service.adoc[install OliveTin as a Windows service]. + +== Download and run + +1. You can download the latest version of OliveTin here: link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-windows-amd64.zip[`OliveTin-windows-amd64.zip`] +2. Unzip and run "OliveTin.exe" + +include::partial$install/windows_service_logs.adoc[] + +include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/install/windows_service.adoc b/docs/modules/ROOT/pages/install/windows_service.adoc new file mode 100644 index 0000000..3365bdd --- /dev/null +++ b/docs/modules/ROOT/pages/install/windows_service.adoc @@ -0,0 +1,67 @@ += Windows Service install + +This option is to install OliveTin as a Windows service, which allows it to run in the background and start automatically when the system boots up. This is useful for servers or systems that need to run OliveTin without user intervention. If you want to run OliveTin as a regular application, you can follow the xref:install/windows.adoc[Windows install] instructions instead. + +== Download and extract; + +[NOTE] +There is no .msi installer for OliveTin yet, so you will need to download the .zip file and extract it in the desired location. + +You can download the latest version of OliveTin here: link:https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin-windows-amd64.zip[`OliveTin-windows-amd64.zip`] + +* Create c:/Program Files/OliveTin/ +** Copy **OliveTin.exe** into this directory. +** Copy the **webui** directory into this directory. + +* Create c:/ProgramData/OliveTin/ +** Copy the **config.yaml** file into this directory. + +include::partial$install/windows_service_logs.adoc[] + +== Test OliveTin startup + +Open a command prompt and make suer you are in the c:/Program Files/OliveTin/ directory, then run: + +[shell] +---- +./OliveTin.exe +---- + +If everything is set up correctly, you should see the OliveTin service starting up and listening on port 1337. + +== Switch startup mode to a Windows service + +Windows services require executables to run a "service host" thread, which is not started by default for OliveTin on windows. To run OliveTin as a service, you will need to set this in your configuration file; + +include::partial$config-start.adoc[] +---- +serviceHostMode: "winsvc-standard" + +logLevel: info + +actions: + ... +---- + +== Register the service + +Open a command prompt as Administrator and run the following command; + +[WARN] +Make sure to run `sc.exe` and not just `sc`, as the latter is a PowerShell alias for `Set-Content` and does not display any output, which can be very confusing. + +[shell] +---- +sc.exe create OliveTin binPath= "C:\Program Files\OliveTin\OliveTin.exe" start= auto +---- + +== Start the service + +Start the service from the Microsoft Management Console (MMC) or by running the following command; + +[shell] +---- +sc.exe start OliveTin +---- + +include::partial$install/post_generic.adoc[] diff --git a/docs/modules/ROOT/pages/integrations/homeassistant-integration.adoc b/docs/modules/ROOT/pages/integrations/homeassistant-integration.adoc new file mode 100644 index 0000000..816c924 --- /dev/null +++ b/docs/modules/ROOT/pages/integrations/homeassistant-integration.adoc @@ -0,0 +1,39 @@ += Home Assistant (HACS Integration) + +Integrating OliveTin with Home Assistant allows you to control OliveTin from your Home Assistant dashboard. Using the HACS integration is the recommended way to integrate OliveTin with Home Assistant. It is easy to set up and provides a seamless experience. + +If you are not familiar with HACs, it is a custom component for Home Assistant that allows you to install and manage custom integrations easily. It is similar to the Home Assistant Add-ons store but for integrations. + +== Setup guide + +. link:https://hacs.xyz/docs/use/[Install HACS] +. Go to HACS in your home assistant control panel. Click the "..." dropdown in the top right corner, and select "**Custom Repositories**". ++ +image::hacs-dropdown.png[] +. In the dialog that pops up, add the custom repo as follows; ++ +image::hacs-custom-repo.png[] ++ +.. **Repository link**: https://github.com/OliveTin/OliveTin-HomeAssistant +.. **Type**: Integration +. Search for "OliveTin" in the HACS store, and download it. You will probably need to restart Home Assistant for it be registered correctly. ++ +image::hacs-search.png[] ++ +image::hacs-download.png[] +. Go to "Settings" and open "Devices & Services" ++ +image::hass-devices-and-services.png[] +. Click "Add integration" and search for "OliveTin". Note that if it does not show up, you may need to restart Home Assistant. ++ +image::hass-add-integration.png[] +.. Select "OliveTin" +.. Host: http://your-server:1337/api +.. Username: +.. Password: +. Under "Configured", you should see OliveTin. Open it by clicking on the arrow. ++ +image:hass-configure-integration.png[] +. After configuring it, you should see buttons appear in Home Assistant; ++ +image:hass-buttons.png[] diff --git a/docs/modules/ROOT/pages/integrations/homeassistant.adoc b/docs/modules/ROOT/pages/integrations/homeassistant.adoc new file mode 100644 index 0000000..4a15eed --- /dev/null +++ b/docs/modules/ROOT/pages/integrations/homeassistant.adoc @@ -0,0 +1,62 @@ +[#hass] += Home Assistant (REST) + +[NOTE] +The recommended way to integrate HomeAssistant and OliveTin is via the xref:integrations/homeassistant-integration.adoc[HACS integration]. + +Home Assistant is able to call REST API endpoints, making integration with OliveTin possible without any custom plugins or integrations in Home Assistant. This does require modifying your Home Assistant configuration.yml file though. + +== Give you actions an ID + +First, you need to give your actions an ID. This is done by adding an `id` field to your action. This ID will be used by Home Assistant to call the correct action. Here is an example of an action with an ID: + +[source, yaml] +---- +actions: + - id: "server_sleep" + title: "Server Sleep" + icon: ping + shell: ssh user@server "sudo systemctl suspend" +---- + +You then need to know the URL to call to trigger this action. This URL is the OliveTin API URL, with the action ID appended to it. For example, if your OliveTin is running at `http://yourserver:1337`, the URL to call to trigger the action above would be `http://yourserver:1337/api/StartActionAndWait/server_sleep`. + +You can learn more about starting actions via the OliveTin API by reading the link xref:api/start_action.adoc[Starting Actions via the API] page, but the method "StartActionAndWait" is the one you will want to use for Home Assistant. + +== Add the REST API call to Home Assistant + +Now that you have the URL to call to trigger your action, you can add this to your Home Assistant configuration. This is done by adding a `rest_command` to your configuration.yml file. + +* link:https://www.home-assistant.io/docs/configuration/[Home Assistant Configuration] + +That page assumes you will use the Home Assistant File Editor addon to edit your configuration.yaml. Install it from the Home Assistant addon store if you have not done so already; + +image::hassFileEditor.png[] + +The addon is started and added to the sidebar; + +image::hassFileEditorConfig.png[] + +Here is an example of a `rest_command` that calls the action above: + +From the file editor now in your sidebar, browse the filesystem to the configuration.yaml file and add the following to the file: + +[source, yaml] +---- +rest_command: + olivetin_sleep_mindstorm: + url: http://olivetin.webapps.teratan.lan/api/StartActionByGetAndWait/server_sleep + method: get +---- + +You save the file, and restart Home Assistant to pick up the changes. + +== Add a button to your HASS Dashboard + +Now that you have a `rest_command` set up to call your action, you can add a button to your Home Assistant dashboard to trigger the action. This is done by adding a `button` to your dashboard configuration. + +image::hassButtonSetup.png[] + +Set the "Tap Action" to "Call Service" and select the `rest_command` you created earlier. You can also set the icon and name of the button to whatever you like. + +Good luck! diff --git a/docs/modules/ROOT/pages/integrations/mcp.adoc b/docs/modules/ROOT/pages/integrations/mcp.adoc new file mode 100644 index 0000000..a1f82f5 --- /dev/null +++ b/docs/modules/ROOT/pages/integrations/mcp.adoc @@ -0,0 +1,12 @@ += OliveTin and MCP Servers + +OliveTin does not yet include a built-in link:https://modelcontextprotocol.io[Model Context Protocol (MCP)] server integration, and there is no current roadmap to add one to OliveTin itself. + +== Community project + +A community-maintained MCP server for OliveTin is available at link:https://github.com/vaddisrinivas/olivetin-mcp[olivetin-mcp]. + +[NOTE] +==== +This project is not maintained by the OliveTin project maintainers. For issues specific to the MCP server, please use that repository. If you run into problems on the OliveTin side, the OliveTin community will try to help where it can. +==== diff --git a/docs/modules/ROOT/pages/integrations/n8n.adoc b/docs/modules/ROOT/pages/integrations/n8n.adoc new file mode 100644 index 0000000..4d594d5 --- /dev/null +++ b/docs/modules/ROOT/pages/integrations/n8n.adoc @@ -0,0 +1,15 @@ += OliveTin n8n Integration + +The OliveTin n8n node is a community node for link:https://n8n.io[n8n] that lets you trigger OliveTin actions from your n8n workflows. + +* **npm package**: `n8n-nodes-olivetin` +* **Repository**: link:https://github.com/OliveTin/OliveTin-n8n-node[OliveTin-n8n-node] + +== Installation + +Install the node in n8n via the Community nodes panel, or install the package in your n8n environment. + +[NOTE] +==== +Detailed installation steps and usage documentation will be added here in a future update. +==== diff --git a/docs/modules/ROOT/pages/integrations/stream-deck.adoc b/docs/modules/ROOT/pages/integrations/stream-deck.adoc new file mode 100644 index 0000000..57b727b --- /dev/null +++ b/docs/modules/ROOT/pages/integrations/stream-deck.adoc @@ -0,0 +1,33 @@ += OliveTin Stream Deck Plugin + +[NOTE] +Plugin has been developed, and is waiting for approval on the Marketplace. + +== Get the plugin on the Marketplace + +Head over to the link:https://marketplace.elgato.com/stream-deck/plugins[Elegato Marketplace], and search for "OliveTin". + +image::stream-deck/marketplace.png[] + +Click "Get" on the plugin to install it onto your Stream-Deck. + +== Configure a button + +Here are some screenshots, and later documentation will follow when the plugin gets approved. + +Add the OliveTin button; + +image::stream-deck/panel.png[] + +Set the OliveTin API URL to; + +http://yourserver:1337/api/ + +image::stream-deck/config.png[] + +Switch to the **Input** tab, and the enter an action ID: + +image::stream-deck/inputs.png[] + +If you don't have IDs set on your action, then read xref:action_customization/ids.adoc[how to set action IDs]. + diff --git a/docs/modules/ROOT/pages/logs/actions.adoc b/docs/modules/ROOT/pages/logs/actions.adoc new file mode 100644 index 0000000..4bb8741 --- /dev/null +++ b/docs/modules/ROOT/pages/logs/actions.adoc @@ -0,0 +1,79 @@ +[#log-levels] += Action logs + +[NOTE] +There are two different types of logs in OliveTin - xref:advanced_configuration/logs.adoc[application logs] and xref:logs/actions.adoc[action logs]. This page is about the __action logs__, which are the logs that are generated by the actions that you run in OliveTin. + +== Controlling access to see action logs + +You can control access to action logs using **permissions**, which are assigned to actions. + +=== Disabling logs for all users + +[source, yaml] +---- +logLevel: info + +defaultPermissions: + logs: false +---- + +=== Enabling logs for a specific ACL + +[source, yaml] +---- +logLevel: info + +defaultPermissions: + logs: false + +accessControlLists: + - name: admin + matchUsernames: + - alice + - bob + permissions: + logs: true + + addToEveryAction: true + +actions: + - title: My Action + shell: echo "Hello World" +---- + +== Disabling the log viewer + +You can disable the log viewer in the interface with xref::security/acl.adoc#_acls_and_policies_global[security policy configuration], using the defaults, or via an ACL. Examples are shown below for each of these methods. + +=== Disable log viewer for all users; + +[source, yaml] +---- +logLevel: info +defaultPolicy: + showLogList: false +---- + +=== Disable log viewer expect for admin users + +[source, yaml] +---- +logLevel: info + +defaultPolicy: + showLogList: false +accessControlLists: + - name: admin + matchUsernames: + - alice + - bob + policy: + showLogList: true +---- + +== See Also + +* xref:logs/saving.adoc[Saving action logs] +* xref:advanced_configuration/logs.adoc[Application logs] +* xref:logs/calendar.adoc[Calendar view] diff --git a/docs/modules/ROOT/pages/logs/calendar.adoc b/docs/modules/ROOT/pages/logs/calendar.adoc new file mode 100644 index 0000000..89ca021 --- /dev/null +++ b/docs/modules/ROOT/pages/logs/calendar.adoc @@ -0,0 +1,57 @@ +[#logs-calendar] += Calendar view + +The logs calendar view provides a visual way to browse your action execution history. Instead of scrolling through a list of logs, you can see executions displayed on a calendar, making it easy to identify patterns, busy periods, or specific dates when actions were run. + +== Accessing the Calendar View + +To access the calendar view, navigate to the **Logs** page and click the **Calendar** button in the toolbar. This will switch from the list view to the calendar view. + +You can return to the list view at any time by clicking the **Back to list** button. + +image::logs/views/logsCalendar.png[] + +== Features + +=== View Executions on a Calendar + +Each action execution is displayed as an event on the calendar. Events show: + +* The action title +* The action icon (if configured) + +This gives you a quick visual overview of when actions were executed throughout the month. + +=== Navigate Between Months + +Use the navigation controls at the top of the calendar to move between months. The calendar will display all logged executions for the visible month. + +=== Click on an Event + +Click on any event (execution) on the calendar to view the full execution details, including: + +* The complete output of the action +* Execution timing information +* Exit code and status +* User who triggered the action + +=== Click on a Date + +Click on any date on the calendar to filter the logs list view by that specific day. This is useful when you want to see all executions from a particular date in the traditional list format. + +After clicking a date, you'll be redirected to the logs list view with a date filter applied. You can clear this filter using the clear button next to the timestamp header. + +== Use Cases + +The calendar view is particularly useful for: + +* **Auditing** - Quickly identify when specific actions were run +* **Troubleshooting** - Find executions around a specific date when issues occurred +* **Pattern recognition** - Visualize how often scheduled actions run +* **Historical review** - Get an overview of system activity over time + +== See Also + +* xref:logs/actions.adoc[Action logs] +* xref:advanced_configuration/logs.adoc[Application logs] +* xref:logs/saving.adoc[Saving logs] diff --git a/docs/modules/ROOT/pages/logs/intro.adoc b/docs/modules/ROOT/pages/logs/intro.adoc new file mode 100644 index 0000000..16180a6 --- /dev/null +++ b/docs/modules/ROOT/pages/logs/intro.adoc @@ -0,0 +1,38 @@ +[#logs] += Logs + +OliveTin records an entry every time an action runs. The **Logs** pages in the web interface let you browse that history, open individual executions, and filter or search past runs. + +Each log entry includes: + +* When the action started (and finished, if applicable) +* The action title and icon +* Who triggered it +* Status (for example, completed, timed out, or still running) +* A link to the full execution output + +== Logs in the web interface + +Use the **Logs** link in the navigation to open the list view. From there you can: + +* Search and filter executions +* Open the xref:logs/calendar.adoc[calendar view] to browse by date +* Open the xref:logs/queue.adoc[queue view] when executions are waiting for a concurrency slot +* Click an action title to see full output, timing, and exit status + +image::logs/views/logsList.png[] + +== Application logs vs action logs + +OliveTin has two different kinds of logging: + +* **Action logs** — history of commands you run through OliveTin (this section) +* **Application logs** — diagnostic output from the OliveTin service itself (see xref:advanced_configuration/logs.adoc[Logging - Application]) + +== What's next? + +* xref:logs/actions.adoc[Action logs] — permissions and visibility for the log viewer +* xref:logs/calendar.adoc[Calendar view] — browse executions on a calendar +* xref:logs/queue.adoc[Queue view] — see executions waiting for a concurrency slot +* xref:logs/saving.adoc[Saving logs] — persist logs to disk across restarts +* xref:action_customization/timeouts.adoc[Timeouts] — timed-out actions appear in the logs with a **Timed out** status diff --git a/docs/modules/ROOT/pages/logs/queue.adoc b/docs/modules/ROOT/pages/logs/queue.adoc new file mode 100644 index 0000000..f073692 --- /dev/null +++ b/docs/modules/ROOT/pages/logs/queue.adoc @@ -0,0 +1,19 @@ +[#logs-queue] += Queue view + +When actions belong to an xref:action_customization/concurrency.adoc[action group] and the group is at its concurrency limit, additional executions wait in a queue until a slot is free. + +The **Queue** page shows those waiting executions grouped by action group, including queue position and status. + +== Accessing the queue view + +From the **Logs** page, click the **Queue** button in the toolbar to open the queue view. + +You can return to the list view at any time using **Back to list**. + +image::logs/views/logsQueue.png[] + +== See also + +* xref:logs/intro.adoc[Logs overview] +* xref:action_customization/concurrency.adoc[Concurrency and action groups] diff --git a/docs/modules/ROOT/pages/logs/saving.adoc b/docs/modules/ROOT/pages/logs/saving.adoc new file mode 100644 index 0000000..d69d181 --- /dev/null +++ b/docs/modules/ROOT/pages/logs/saving.adoc @@ -0,0 +1,62 @@ +[#save-logs] += Saving logs + +By default, OliveTin only keeps logs in memory, meaning that if you restart OliveTin your logs will be lost. For some use cases this is acceptable, but you can configure OliveTin to save logs for you. + +You can configure the global setting for saving logs, or override it on a per-action basis; + +[source,yaml] +.`config.yaml` +---- +saveLogs: + resultsDirectory: /var/log/OliveTin/results/ + outputDirectory: /var/log/OliveTin/output/ + + +actions: + # This will use the default `saveLogs` setting. + - title: date + shell: date + + # This will override the default `saveLogs` setting. + - title: date2 + shell: date + saveLogs: + resultsDirectory: /logs/ + outputDirectory: /logs/ + +---- + +From the above example, you can see there There are two types of logs - **results (.yaml)** and **output (.log)** + +* **Results (.yaml)** - this captures almost everything that OliveTin knows about the action and looks like this. ++ +[source,yaml] +.Example results - date.1714333384.5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2.yaml +---- +datetimestarted: 2024-04-28T20:43:04.426754136+01:00 +datetimefinished: 2024-04-28T20:43:04.436596926+01:00 +stdout: | + Sun 28 Apr 20:43:04 BST 2024 +stderr: "" +timedout: false +blocked: false +exitcode: 0 +tags: [] +executionstarted: true +executionfinished: true +executiontrackingid: 5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2 +process: + pid: 4168638 +actiontitle: date +actionicon: '😀' +actionid: d3cf6e25-8bab-432d-b4f9-e6f531b2b67b +---- + +* **output (.log)** - this just captures the output - stdout, stderr from an execution, ++ +[source] +.Example output - date.1714333384.5e2dc9e5-b6b3-445b-bff9-c2082b0bbbb2.log +---- +Sun 28 Apr 20:43:04 BST 2024 +---- diff --git a/docs/modules/ROOT/pages/reference/containerInstallPackages.adoc b/docs/modules/ROOT/pages/reference/containerInstallPackages.adoc new file mode 100644 index 0000000..4d1eaa2 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/containerInstallPackages.adoc @@ -0,0 +1,37 @@ +[#container-dnf] += Installing extra container packages + +The official OliveTin container image is based on Fedora Linux. Fedora has shown to offer a great mix of stability and support over two decades. The base container image for OliveTin is relatively lightweight, with not many tools installed by default. This keeps the download size small, but you may want to add additional packages. + +== Quickstart - using DNF to install additional packages + +You can of course create your own container image, but this is probably a lot of work for new users, or people who just want a few extra packages/commands. Instead of creating a whole new container image, you can simply run `microdnf` (the Fodora package manager) to install more commands. + +. Start the OliveTin container using one of the methods shown in the xref:install/container.adoc[container installation instructions]. + +. Then, on the same host that is running the container, spawn a root shell inside the OliveTin container, like this; ++ +---- +user@host: docker exec -it olivetin -u root /bin/bash +[root@019d08ef95bd /]# +---- ++ +The important thing here is passing `-u root`. By default, OliveTin does not run as root. + +. Once you have a root shell in OliveTin, you can use the Fedora package manager - `microdnf` to install things that you might need. If you are used to Debian's `apt-get` tool, it works in a very similar way; ++ +---- +[root@019d08ef95bd /]# microdnf install -y nc +---- ++ +Note that if you upgrade the OliveTin container image, you will need to reinstall these packages. ++ +Once you have finished installing these packages, just exit the root shell using `exit`. You don't need to restart the container - and OliveTin does not need to run as root to use most commands. + +== See also + +* link:https://hub.docker.com/r/jamesread/olivetin[OliveTin container on Docker Hub] +* xref:install/container.adoc[Installing using a container] +* xref:install/docker_compose.adoc[Installing using docker compose] +* xref:install/helm.adoc[Installing on Kubernetes with Helm] +* xref:install/k8s.adoc[Installing on Kubernetes (manually)] diff --git a/docs/modules/ROOT/pages/reference/contribute.adoc b/docs/modules/ROOT/pages/reference/contribute.adoc new file mode 100644 index 0000000..8d0e4f1 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/contribute.adoc @@ -0,0 +1,14 @@ +[#contribute] += Contribute + +First of all, a huge, huge thanks for reading this page, and considering some form of contribution. Here are some suggestions below. + +NOTE: OliveTin does not accept xref:reference/donations_and_sponsorship.adoc[Donations and Sponsorship]. + +. If you have 2 Minutes to contribute: **Share how you are using OliveTin**, on Reddit, Twitter/X, LinkedIn, Mastodon, or whatever - use the hashtag #OliveTin. Show a screenshot or blog about it. Tell people how OliveTin helped you. There is also #screenshot-showcase in the OliveTin Discord community. If you want to ping me directly, I really like getting email or PMs; jump on Discord and just say it, or contact me via one of the methods found at http://jread.com . +. If you have 10 minutes to contribute: **Answer a call for support**: look for support issues in #support on discord, or tagged om GitHub issues, and help someone out! You don't have to solve the problem, just point someone in the right direction. +. If you have 15 minutes to contribute: +.. **Write up a feature request** on GitHub issues +.. **Improve the docs** - I make a LOT of typos! https://github.com/OliveTin/docs.olivetin.app +. **If you have lots of time:** contribute code! https://github.com/OliveTin/OliveTin/blob/main/CONTRIBUTING.adoc +. **If you have spare compute** - spare server capacity where I can have a virtual machine with root access, then having more VMs to test OliveTin on is always very welcome indeed. I have a lot of sever capacity already though personally, so I'm probably just being greedy for CPU and RAM :-) diff --git a/docs/modules/ROOT/pages/reference/donations_and_sponsorship.adoc b/docs/modules/ROOT/pages/reference/donations_and_sponsorship.adoc new file mode 100644 index 0000000..ba51e6d --- /dev/null +++ b/docs/modules/ROOT/pages/reference/donations_and_sponsorship.adoc @@ -0,0 +1,11 @@ +[#donations-and-sponsorship] += Donations & Sponsorship + +Sometimes I (James Read) get asked if people can donate money, or sponsor me for OliveTin. If you are reading this page, maybe you are thinking the same. + +**I do not accept donations or sponsorship** for OliveTin, but I want to **thank you very much indeed** for thinking about the potential. + +I have a job that pays me, where I don't write code as part of my day job (very often) - I enjoy coding as a hobby in my spare time. If I get money for that, it somehow takes the pleasure or fun out of it, or makes it feel like a job. There are concerns that I might "prefer" to work on one user's feature request if I am sponsored, or even feel compelled that I have to work on something because someone gave me money. I don't want OliveTin development to go that way. There are also other little considerations, like being paid on the side out of my job then affects my job, and tax, and so on and so on. This is why I don't take any form of money, donations or sponsorship. + +Another way that you can show your appreciation for OliveTin, that actually means a lot more than money, is to xref:reference/contribute.adoc[contribute to OliveTin]. There are little ways and big ways to contribute - depends on if you have 2 minutes or 2 weeks to give! + diff --git a/docs/modules/ROOT/pages/reference/exitCodes.adoc b/docs/modules/ROOT/pages/reference/exitCodes.adoc new file mode 100644 index 0000000..532bc31 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/exitCodes.adoc @@ -0,0 +1,20 @@ += Understanding exit codes + +OliveTin just runs commands. If the command exits with an unusual exit code +(something other than 0), OliveTin will tell you. Many Linux commands will exit +with code 1, 2, 3, etc to indicate different types of errors. + +It's important to understand that OliveTin is just reporting back what the +command exited with, it's very unusual for OliveTin to cause new types of +errors! + +For example, if `ping` exits with code 1 or 2, the documentation for ping says +that this indicates either a name not found, timeout, or other similar error. +The best thing you can do is `man ping` to read the ping manual page to find +out more. + +== Common error codes + +* **Exit code 127** is used by the Linux shell to indicate "Command not found". Most often this means you need to install the command (often in the linux container image). + + diff --git a/docs/modules/ROOT/pages/reference/includes.adoc b/docs/modules/ROOT/pages/reference/includes.adoc new file mode 100644 index 0000000..c961632 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/includes.adoc @@ -0,0 +1,26 @@ += Includes + +OliveTin 3k supports including configuration files from other files. This is useful for organizing large configurations or reusing common settings across multiple actions. + +== Include Syntax + +To include another configuration file, use the following syntax in your main configuration file: + +[source,yaml] +---- +include: config.d +---- + +This will include all config files in the /config.d/ directory. + +== Include Logic + +Files are included in alphabetical order based on their filenames. This allows you to control the order of inclusion by naming your files accordingly. For example; + +* `01-setup.yaml` contains `logLevel: debug` +* `02-actions.yaml` contains `logLevel: info` +* Final `logLevel` will be `info` since `02-actions.yaml` is included after `01-setup.yaml`. + +Everything under `actions` is merged into a single `actions` list after all files are included. This means you can define actions in multiple files and they will be combined into one list. + +WARNING: All other "lists" are overwritten by later files. For example, if you define `dashboards`, `entities`, `accessControlLists` or similar in multiple files, only the last definition will be used. diff --git a/docs/modules/ROOT/pages/reference/multiple_instances.adoc b/docs/modules/ROOT/pages/reference/multiple_instances.adoc new file mode 100644 index 0000000..7f46cf2 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/multiple_instances.adoc @@ -0,0 +1,59 @@ +[#multi-inst] += Multiple instances on a server + +Several users will find themselves wanting to run multiple instances of OliveTin. Depending on how you've setup OliveTin depends on how easy it is to configure that. This page includes instructions for OliveTin installed as a container, and as a package (.tar.gz). + +== With Containers + +This is the easiest way to run multiple OliveTin instances. Follow the xref:install/container.adoc[Container Installation instructions], or similar for xref:install/docker_compose.adoc[Docker Compose], xref:install/helm.adoc[Helm] or similar to get started. + +1. Create a `config.yaml` file for each instance of OliveTin (instances cannot share the same config). +2. Choose a new external port for OliveTin and set it in the config file (by default that is `1337` is used). For example, set `listenAddressSingleHTTPFrontend: 0.0.0.0:2337` for your 2nd container's config. +3. When creating the container, pass in the 2nd instance's config, eg; `-v /opt/OliveTin_two/:/config/` +4. When creating the container, set the external port, eg: `2337:1337` - 2337 is the external port) + +You do not need to change the listenAddresses / ports for the other 3 ports that OliveTin uses, when you are running inside a container. + +== Without containers - using a package (.tar.gz) + +If you are not using containers, then it is probably best not to use a `.deb/.rpm` installation, as those packages can only be installed for one instance. + +Instead, follow the instructions for xref:install/targz.adoc[installing from a .tar.gz] archive. + +When you come to create the config.yaml file, OliveTin will look for this in it's own startup directory. Therefore it is probably best to extract the .tar.gz file like this and change the paths; + +* `/opt/OliveTin_one/` +* `/opt/OliveTin_two/` +* `/opt/OliveTin_three/` + +Because you are running outside of a container, you will also need to change the "internal" ports used by OliveTin so they are separate for all instances. OliveTin listens on 4 addresses (1 external, 3 internal) and needs 4 ports. You can read about these in the xref:reference/network-ports.adoc[network ports documentation]. + +NOTE: OliveTin also supports reading the PORT environment variable, and will use this as a base port for the simgle frontend, will add 1 to start extra servers. For example of PORT is 2000, then the simgle frontend will start on port 2000, the REST API on 2001, and so on. + +You could end up with a setup that looks like this; + +[%header] +|=== +| Instance Name | Install path | Config file path | Single frontend point (`listenAddressSingleHTTPFrontend`) | REST Actions port (`listenAddressRestActions`) | gRPC Actions port (`listenAddressGrpcActions`) | WebUI Port (`listenAddressWebUI`) +| OliveTin_one | `/opt/OliveTin_one` | `/opt/OliveTin_one/config.yaml` | `0.0.0.0:1337` | `localhost:1338` | `localhost:1339` | `localhost:1340` +| OliveTin_two | `/opt/OliveTin_two` | `/opt/OliveTin_two/config.yaml` | `0.0.0.0:2337` | `localhost:2338` | `localhost:2339` | `localhost:2340` +| OliveTin_three | `/opt/OliveTin_three` | `/opt/OliveTin_three/config.yaml` | `0.0.0.0:3337` | `localhost:3338` | `localhost:3339` | `localhost:3340` +|=== + +Note that you will also need to adjust the default systemd service file to point to your install directory, if using that. Here is an example for `OliveTin_two`; + +.A modified systemd service file for a 2nd instance +---- +[Unit] +Description=OliveTin2 + +[Service] +WorkingDirectory=/opt/OliveTin_two/ +ExecStart=/opt/OliveTin_two/OliveTin +Restart=always + +[Install] +WantedBy=multi-user.target +---- + + diff --git a/docs/modules/ROOT/pages/reference/network-ports.adoc b/docs/modules/ROOT/pages/reference/network-ports.adoc new file mode 100644 index 0000000..5453241 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/network-ports.adoc @@ -0,0 +1,56 @@ +[#network-ports] += Network ports + +OliveTin might surprise some people when they see it is listening on several +ports when it starts up. Most of these ports are internal and localhost-only by +default. It keeps the architecture of OliveTin clean and simple, and allows for +a lot of flexibility if needed. + +== Network flow diagram + +Here is the default flow of traffic in OliveTin without any config changes. + +[mermaid,png] +.Flow of an inbound network request +.... +%%{init: {'theme': 'neutral'}}%% +graph LR + A[Your Browser] -->|HTTPS 443/tcp| C + C["Single HTTP frontend"] + H["Prometheus"] + B["gRPC API"] + + subgraph "OliveTin service" + C -->|/api/| D[REST API] --> B + C -->|/| E[webui] + C -->|/metrics/| H + end +.... + +1. Traffic comes into OliveTin over your network and hits the only port +listening - 1337, which listens on all interfaces. This is a micro HTTP reverse +proxy. +2. Traffic for `/` gets proxied to `localhost:1340` for the static web +server. +3. Traffic for `/api/` gets proxied to `localhost:1338` for REST actions. +4. The REST API actually makes gRPC API calls internally, to port +`localhost:1339`. + +Below is a detailed reference table. + +== Port Reference Table + +.Port reference table +[%header,cols="1,2"] +|=== +| Config file reference (and Default Address:Port) | Purpose +| `listenAddressSingleHTTPFrontend: 0.0.0.0:1337` (listen on all available addresses) | This is a "micro reverse proxy" built into OliveTin. It's only purpose is to serve /ui and / (the web interface) from a single endpoint. This means that problems like CORSs and setting "external addresses" is not necessary. It does not do any caching or anything else. It can be disabled, but it makes life a lot easier for you. It's common to put your own reverse proxy like haproxy, traefik, etc in front of this single micro reverse proxy. +| `listenAddressRestActions: localhost:1338` | REST - the protocol used by web pages to talk to web APIs. In the case of OliveTin, the API is used to get actions, and start actions. +| `listenAddressGrpcActions:localhost:1339` | gRPC - a very popular method of service-to-service API communication. This provides the "real" API for OliveTin. +| `listenAddressWebUI: localhost:1340` | Hosts a simple static web server with some HTML, stylesheets, Javascript etc for the web interface. +| `listenAddressPrometheus: localhost:1341` | Hosts a prometheus endpoint, which is disabled by default. See xref:advanced_configuration/prometheus.adoc[Prometheus] to learn more. +|=== + +== See also + +* xref:reference/multiple_instances[Running Multiple instances of OliveTin on the same server] diff --git a/docs/modules/ROOT/pages/reference/reference_snapshots.adoc b/docs/modules/ROOT/pages/reference/reference_snapshots.adoc new file mode 100644 index 0000000..9d2edeb --- /dev/null +++ b/docs/modules/ROOT/pages/reference/reference_snapshots.adoc @@ -0,0 +1,25 @@ +[#snapsnots] += Snapshot builds + +It's sometimes useful to test code changes in OliveTin that are still in development - and have not yet made it into an official version, yet. Thankfully, all code changes are automatically compiled into a "snapshot" builds and are saved in GitHub actions. + +If you browse to GitHub actions page for OliveTin, you'll find the "Build Snapshot" job, with a list of recent builds. + +* https://github.com/OliveTin/OliveTin/actions/workflows/build-snapshot.yml[OliveTin's Build Snapshot page] + +image::snapshots.png[] + +Most of the time you will want to select the top build, unless you've specifically been given a build link to use. + +== Download the snapshot archive + +On the job page, you will have a single "snapshot" file listed. In this screenshot, it is 109 MB. + +image::snapshot-download.png[] + +Once downloaded, you can open the archive using any tool that you use to open .zip files. The contents should read something like this; + +image::snapshot-archive.png[] + +Extract the file you need, and off you go! + diff --git a/docs/modules/ROOT/pages/reference/reference_themes_for_developers.adoc b/docs/modules/ROOT/pages/reference/reference_themes_for_developers.adoc new file mode 100644 index 0000000..d64508a --- /dev/null +++ b/docs/modules/ROOT/pages/reference/reference_themes_for_developers.adoc @@ -0,0 +1,51 @@ +[#themes-dev] += Themes (for theme developers) + +== Step by step theme guide + +OliveTin themes are simply a directory of CSS and other assets. OliveTin looks for a directory called `custom-webui/themes/` in the same directory as your `config.yaml` file. + +Start by creating a directory called `custom-webui/themes/` in the same directory as your `config.yaml` file. This is where you will put your theme files. A theme must also have a theme.css file, which is the main CSS file for your theme. This file must be called `theme.css` and must be in the same directory as your theme folder. + +* OliveTin will by default only read theme.css once on startup. If you are intending to change theme.css while OliveTin is running, set `themeCacheDisabled: true` in your config.yaml. This will make OliveTin read theme.css on every request, and is useful for development. +* Go to https://github.com/OliveTin/theme-template and use this template repository to create your new theme repository on GitHub. +* Install OliveTin somewhere, and clone your new repository using `git clone` into your themes directory. +* Set `themeName: ` in your OliveTin config.yaml and restart OliveTin. + +Write beautiful CSS to create your theme as you like it, commit your changes to git. + +Note that OliveTin will load `/theme.css` depending on `themeName:` in your config file. Images and any other assets will be served at `/custom-webui/themes/mytheme/`. + +== Understanding theme URLs + +When you create a theme, OliveTin will serve your theme's CSS at `/theme.css` and any other assets at `/custom-webui/themes/mytheme/`. This might be a little strange at first, as your theme.css file will be in the `/custom-webui/themes/mytheme/` directory, but OliveTin will still serve it at `/theme.css`. Let's explain why this happens; + +OliveTin wants to make it easy for your reverse proxy, cache server, or browser, to cache as much content as possible. This means that if OliveTin had to inject a new CSS file into the HTML every time you changed your theme, then your reverse proxy, cache server, or browser would have to re-download the HTML every time you changed your theme. This is not ideal. + +It is possible that OliveTin's initial webUiSettings.json (that is loaded to setup the page), could include the theme name, and then the JavaScript could then add an extra stylesheet to load, but this is slow, and creates a horrible "page flash" effect as the theme is requested. + +To make things fast, OliveTin will copy the content of your `/custom-webui/themes/mytheme/theme.css` file into memory when it starts, and then requests for `/theme.css` will load this file. + +What this means for you, is that to get to files like `background.png` from your CSS, you must write your CSS to point to the file in the `/custom-webui/themes/mytheme/` directory; + +.Correct example +``` +body { + background-image: url('/custom-webui/themes/mytheme/background.png'); +} +``` + +.Incorrect example +``` +body { + background-image: url('/background.png'); +} +``` + +== How to list your theme on the OliveTin themes page + +The OliveTin themes page is here; https://olivetin.app/themes + +When you are done with your theme, fork https://github.com/OliveTin/themes on GitHub and create a new page under the "content" directory for your new theme. Commit that to GitHub and then raise a pull request. + +If you need more help, please jump on our Discord server! diff --git a/docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc b/docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc new file mode 100644 index 0000000..f983447 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc @@ -0,0 +1,96 @@ +[#themes] += Themes (for users) + +You can look for themes on the link:http://www.olivetin.app/themes/[OliveTin Theme Site]. + +== Installing a theme + +There are 3 ways to install a theme; + + +If running inside a Docker container: + +1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory. + +If running without using containers: + +1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory. +2. Git Clone the theme into your `custom-webui/themes/` directory. + +[#get-theme] +== How to use the `olivetin-get-theme` command + +The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file; + +[source,bash] +---- +actions: + - title: Get OliveTin Theme + shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }} + icon: theme + arguments: + - name: themeGitRepo + title: Theme's Git Repository + description: Find new themes at https://olivetin.app/themes + type: url + + - name: themeFolderName + title: Theme's Folder Name + type: ascii_identifier +---- + +When you are browsing the OliveTin Theme Site, you can click on a theme and see it's Git Repository URL. You can then copy this URL and paste it into the `olivetin-get-theme` command. + +== Where do I find my themes directory? + +When OliveTin starts up, it will try to create a directory called `custom-webui/themes/` in your config directory. This directory is where you can put your own custom themes. + +OliveTin will then serve this theme directory at `http://yourserver:1337/custom-webui/themes/`, this means that all theme content should go into `/custom-webui/themes/mytheme/`. + +Install Themes into your `custom-webui/themes/` directory, which should be in your config directory. If this directory does not exist, you can create it. + +[source,yaml] +---- +├── config.yaml +├── custom-webui +│ └── themes +│ └── custom-icons +│ ├── icon.png +│ └── theme.css +├── entities +│ ├── containers.json +│ ├── heating.yaml +│ ├── servers2.yml +│ ├── servers.yaml +│ └── systemd_units.json +└── installation-id.txt +---- + +== Create your own theme (without any intention of publishing it) + +Create a sub-directory under your theme directory (eg `custom-webui/themes/mytheme`); + +Set your theme in your config + +[source,yaml] +.`config.yaml` +---- +themeName: mytheme +---- + +Add your css into `custom-webui/themes/mytheme/theme.css`. + +Your theme css will be loaded "on top" of the existing stylesheet. + +To test it is working, set your theme CSS to something ridiculous like; + +---- +body { + background-color: red !important; +} +---- + +Profit. + +Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes. + diff --git a/docs/modules/ROOT/pages/reference/release_policy.adoc b/docs/modules/ROOT/pages/reference/release_policy.adoc new file mode 100644 index 0000000..6fb8da4 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/release_policy.adoc @@ -0,0 +1,29 @@ += Release Policy + +This page is a collection of notes around the policy for releases. + +== Package and tag deletions + +The OliveTin project *WILL* delete packages and tags in the following situations; + +* A release went out accidently, or the package is broken in a way that prevents installation or use (something critical). +* A release went out with the wrong tag, or wrong tag format (eg 2025-11-06 instead of 2025.11.06) for OliveTin 2k. + * This is because registries that contain bag tag formats that do not match smever will cause issues for users trying to install or upgrade in the future. + +It is understood that this can break some users workflows, but deleting packages occours rarely, and is only done to prevent further issues. + +If this is really a concern that you want to guard against, it is recommended to run your own private registry/mirror of OliveTin packages. + +Deleted packages will not be supported in any way; + +== History of deleted packages; + +* **OliveTin 2025-11-06** - deleted due to wrong tag format (2025-11-06 instead of 2025.11.06) +** GitHub releases page +** GHCR +** Docker Hub + +* **OliveTin 2025-10-30** - deleted due to wrong tag format (2025-10-30 instead of 2025.10.30) +** GitHub releases page +** GHCR +** Docker Hub diff --git a/docs/modules/ROOT/pages/reference/updateChecks.adoc b/docs/modules/ROOT/pages/reference/updateChecks.adoc new file mode 100644 index 0000000..9f65bb9 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/updateChecks.adoc @@ -0,0 +1,20 @@ +[#update-checks] + += Update Checks + +NOTE: This page is for OliveTin versions **2024.06.02** and afterwards. Previous versions of OliveTin used to have a form of tracking. To learn about how that worked, see xref:reference/updateTracking.adoc[update tracking]. + +OliveTin has the ability to check for updates, which is now turned OFF if nothing is specified in your configuration file. To enable this feature, set the following in your `config.yaml` file; + +[source,yaml] +.`config.yaml` +---- +checkForUpdates: true +---- + +By enabling this feature, OliveTin will; + +* Do a HTTP GET in plaintext (not HTTPS) to http://update-check.olivetin.app/versions.json and download the contents of that file. While the server will see your IP address in it's webserver logs, this information isn't actively used, and the OliveTin project has no intention of actively parsing the logs to use that. +* Once OliveTin has that json file, it will compare the "latestVersion" attribute against the version it is running. +* If there is a later version, it is displayed in the page OliveTin footer. + diff --git a/docs/modules/ROOT/pages/reference/updateTracking.adoc b/docs/modules/ROOT/pages/reference/updateTracking.adoc new file mode 100644 index 0000000..c887548 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/updateTracking.adoc @@ -0,0 +1,121 @@ +[#update-tracking] += Update Checks & Tracking (legacy) + +NOTE: This page is for OliveTin versions **2022-01-06** to **2024.06.02**. To see the current behavior of update checking, go to xref:reference/updateChecks.adoc[update checks] + +The OliveTin server will now check for updates on startup, and every 7 days after that. It will report those updates as a log message in the console. It does not apply any updates, because this is the choice and responsibility of whoever is running OliveTin to decide if, when, and how to apply any updates. + +The information OliveTin sends to the update server is stored/saved., and this could be considered a form of tracking - that is tracking installations, not tracking people. This page hopefully helps explain what, how and why that information is used so you can be informed (and make changes if you wish). + +== Design considerations + +* The source code for the update check (client), and the update service are open and on GitHub, freely auditable. +* A generated installation ID (which is just a UUID), that is used to differentiate between installations <>. +* The update request is sent and stored in plain text - easy to check/audit. +* The update request goes to an obvious domain name - update-check.olivetin.app +* The checkins can be freely viewed by anyone in the public log. +* The update check can be disabled. + +[#update-sent] +== What is sent (and tracked) + +When OliveTin checks for updates, it will send the following; + +* CurrentVersion - eg: 1.0.0 +* CurrentCommit - the Git commit used to build this version +* OS - The update check wants to know your OS, because it’s possible in the future that some versions and updates might not be available for all OSes at the same time. +* Architecture (x86_64, ARM, etc) +* InstallationID - See below + +Here is an example of a log entry that is sent/stored; + +---- +{"CurrentVersion":"dev","CurrentCommit":"nocommit","OS":"linux","Arch":"amd64","InstallationID":"f232d115-255c-4728-ba7f-a8f8b2b10a1f"} +---- + +If you would like to audit the update code, look at the following directory; https://github.com/OliveTin/OliveTin/tree/main/internal/updatecheck + +[#installation-id] +== What is the OliveTin installation ID? + +This is a randomly generated link:https://en.wikipedia.org/wiki/Universally_unique_identifier[UUID] - that is not based on your operating system, not on any of your data. OliveTin tries to create a random installation-id.txt in your config directory when it starts up. + +The reason for creating a installation ID is to tell the differences between installations - without this identifier, we would not know if 10 instances, or 1 instances of OliveTin are running a specific version. + +In older versions of OliveTin, the MachineID was used instead of InstallationID (see below). + +[#machine-id] +== Why do you need my machine ID? + +This was changed in OliveTin - the MachineID is no longer collected, and the project moved to InstallationID instead. The answer is kept here for old versions. + +First of all, OliveTin only sends a hashed version of a unique identifier for your machine. OliveTin does not use your actual machine ID, because this is private, and potentially sensitive information in it’s original form. This hashed version of this ID is used, which should be considered safe - because it’s a hash, it’s not possible to get back to the original sensitive machine ID. + +From a technical perspective, OliveTin uses the golang package `machineiid` to +get as hashed version of your MachineId. OliveTin follows the security +recommendations of that project by using the hashed MachineId; https://github.com/denisbrodbeck/machineid#security-considerations + +The reason for getting the machine ID is to tell the differences between installations - +without this identifier, we would not know if 10 instances, or 1 instances of +OliveTin are running a specific version. + +== Why do you need to store any information at all? + +This is incredibly useful information for project developers to know, because; + +1. It helps the project developers know how long it takes for updates to be applied by users of OliveTin (ie, should updates be released more often / less often) - the installation ID helps track this. +2. This helps the project better understand what are the most popular operating systems and architectures (ie, so more testing can be done). +3. How many old versions of the project to support? + +== What is stored? + +The update service only stores the information that is sent - <>. + +You can audit the update service code here; https://github.com/OliveTin/update-check.olivetin.app + +== What is not sent/stored + +* No information about you, users of your system, no files, nothing else apart from what is mentioned above. +* Not your public IP address, or any information about your network +* Not any information about how you have configured OliveTin, or any actions. + +== Where is the information sent + +To http://update-check.olivetin.app . This is a virtual machine which stores the logs on the machine filesystem. The data is accessible to all who which to view it, in the interest of transparency. + +== Why isn’t this opt-in? + +It seems the majority of software does perform update checks by default like this - Chrome, Firefox, most modern Operating Systems, etc. Because no information about people, or your data is being used, apart from your installation ID, this seems like a safe default. + +Also, if this were to default off, many people probably would not think about turning it on. + +[#disable-update-checks] +== How do I disable update checking? + +If you are worried about privacy, or similar, please do make your concerns known. This is best if this is an open discussion. + +But, simply, to disable this feature, add to your config file; + + checkForUpdates: false + +[#hide-news-versions] +== How can I hide version upgrades in the OliveTin web interface? + +Set the following in your configuration file; + + showNewVersions: false + +OliveTin will need to be restarted for this change to have affect. + +[#hsts] +== Why can't I visit update-check.olivetin.app in my browser? + +The root domain for OliveTin (OliveTin.app) has HSTS turned on - this forces your browser to use SSL (HTTPS - the little encryption padlock) for all subdomains - including www.olivetin.app and docs.olivetin.app. Although both of those websites don't transmit anything that really needs encrypion, the web is certainly moving to having SSL turned on everywhere. It even has a positive impact on search engine rankings! + +The update-check service - which is accessible from update-check.olivetin.app - is designed to be only accessed via the OliveTin app. Non-web browsers, like this OliveTin app, generally ignore HSTS (and therefore don't try and access the update-check site via SSL/HTTPS. + +If you use a non-web browser to try to access the site over HTTP, (eg, curl), you should find it works like normal. + +As mentioned previously, the update-check site deliberately uses does not use SSL/HTTPS, to make it easy for people to audit what is actually being sent to the update site. Tools like tcpdump, wireshark, or others can verify that OliveTin is not sending more information than is described on this page. + + diff --git a/docs/modules/ROOT/pages/reference/version_display.adoc b/docs/modules/ROOT/pages/reference/version_display.adoc new file mode 100644 index 0000000..cdcf886 --- /dev/null +++ b/docs/modules/ROOT/pages/reference/version_display.adoc @@ -0,0 +1,70 @@ +[#version-display] += Version display + +NOTE: This feature was added in OliveTin version 3000.11.0. + +OliveTin can show or hide the application version in the web interface. This is controlled by the **showVersionNumber** policy, which can be set globally or per user/group via xref:security/acl.adoc#_acls_and_policies_global[ACL policies]. + +== What you see + +When **showVersionNumber** is enabled (the default): + +* The page footer shows the application name and version, for example: **OliveTin 2024.06.02**. +* If xref:reference/updateChecks.adoc[update checks] are enabled and a newer version exists, a link to the new version may appear in the footer. +* xref:troubleshooting/server-diagnostics.adoc[Server diagnostics] includes the installed version. + +When **showVersionNumber** is disabled: + +* The footer shows only **OliveTin** (no version number). +* No update-version link is shown, even if update checks are on. +* In server diagnostics output, the version information is redacted, which can be useful for privacy when sharing the report. + +== Configuration + +The policy defaults to `true` for all users. You can change it in the **defaultPolicy** or override it per user or group in an ACL. + +=== Hide version for everyone + +[source,yaml] +.`config.yaml` +---- +defaultPolicy: + showVersionNumber: false +---- + +=== Show version only for some users + +To hide the version by default but show it for certain users (for example, admins): + +[source,yaml] +.`config.yaml` +---- +defaultPolicy: + showVersionNumber: false + +accessControlLists: + - name: admins + matchUsergroups: + - admins + policy: + showVersionNumber: true +---- + +=== Show version for everyone (default) + +If you do not set **showVersionNumber**, it is treated as `true`. To set it explicitly: + +[source,yaml] +.`config.yaml` +---- +defaultPolicy: + showVersionNumber: true + showDiagnostics: true + showLogList: true +---- + +== See also + +* xref:security/acl.adoc[Access Control Lists] — how policies and ACLs work +* xref:reference/updateChecks.adoc[Update Checks] — how OliveTin checks for new versions +* xref:troubleshooting/server-diagnostics.adoc[Server diagnostics] — version is included or redacted based on this policy diff --git a/docs/modules/ROOT/pages/reverse-proxies/apache.adoc b/docs/modules/ROOT/pages/reverse-proxies/apache.adoc new file mode 100644 index 0000000..09fdd8f --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/apache.adoc @@ -0,0 +1,42 @@ +[#apache-path] +[#apache-dns] += Apache HTTPD + +include::partial$reverse-proxies/diagram.adoc[] + +This is an example of how to setup a DNS name based Apache HTTPD proxy for OliveTin. It assumes OliveTin is running on localhost, port 1337. + +./etc/httpd/conf.d/OliveTin.conf +[source,apache] +---- + +ServerName olivetin.example.com +ProxyPreserveHost On +ProxyPass / http://localhost:1337/ +ProxyPassReverse / http://localhost:1337/ + +# Optional: increase timeout for long-lived websocket connections. +# The websocket endpoint is api/olivetin.api.v1.OliveTinApiService/EventStream (default may drop it). +ProxyTimeout 600 + +---- + +[NOTE] +==== +Apache's default proxy timeouts are short for long-lived connections. Without increasing them, the websocket to `api/olivetin.api.v1.OliveTinApiService/EventStream` is likely to disconnect regularly. OliveTin will attempt to reconnect automatically, but setting `ProxyTimeout` (or a per-route `timeout=` on the websocket `ProxyPass`) avoids unnecessary disconnects. +==== + +Note, you virtual host should *not* include a DocumentRoot directive - httpd is just proxying OliveTin, not serving it's actual pages. + +If you proxy the websocket path explicitly, set a per-route timeout (place these *before* the general `ProxyPass /`): + +[source,apache] +---- +ProxyPreserveHost On +ProxyPass /api/olivetin.api.v1.OliveTinApiService/EventStream ws://127.0.0.1:1337/api/olivetin.api.v1.OliveTinApiService/EventStream timeout=600 +ProxyPassReverse /api/olivetin.api.v1.OliveTinApiService/EventStream ws://127.0.0.1:1337/api/olivetin.api.v1.OliveTinApiService/EventStream + +# Optional global default for proxied backends +ProxyTimeout 600 +---- + diff --git a/docs/modules/ROOT/pages/reverse-proxies/caddy.adoc b/docs/modules/ROOT/pages/reverse-proxies/caddy.adoc new file mode 100644 index 0000000..6514663 --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/caddy.adoc @@ -0,0 +1,35 @@ +[#caddy-dns] += Caddy + +include::partial$reverse-proxies/diagram.adoc[] + +Caddy seems to work without any special configuration for websockets. If you see websocket disconnects, you may need to increase timeouts; see xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets]. A simple `Caddyfile` works like this; + +.Caddyfile +[source,nginx] +---- +http://olivetin.example.com { + reverse_proxy * http://localhost:1337 +} +---- + + +[#caddy-path] +== Custom paths + +.Caddyfile +---- +.... + handle {$GLOBAL_PORTAL_PATH}/olivetin* { + redir {$GLOBAL_PORTAL_PATH}/olivetin {$GLOBAL_PORTAL_PATH}/olivetin/ + uri strip_prefix {$GLOBAL_PORTAL_PATH}/olivetin + basicauth { + {$GLOBAL_USER} HASH + } + reverse_proxy * localhost:1337 + } +.... +---- + +include::partial$reverse-proxies/external-rest.adoc[] + diff --git a/docs/modules/ROOT/pages/reverse-proxies/haproxy.adoc b/docs/modules/ROOT/pages/reverse-proxies/haproxy.adoc new file mode 100644 index 0000000..2b0b062 --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/haproxy.adoc @@ -0,0 +1,30 @@ +[#haproxy-dns] += HAProxy + +:proxy: HAProxy +include::partial$reverse-proxies/diagram.adoc[] + +This is a straightforward example of how to setup a DNS name based HAProxy setup for OliveTin. + +./etc/haproxy/haproxy.conf +[source,python] +---- +frontend cleartext_frontend + bind 0.0.0.0:80 + + option httplog + + use_backend be_olivetin_webs if { hdr(Host) -i olivetin.example.com && path_beg /websocket } + use_backend be_olivetin_http if { hdr(Host) -i olivetin.example.com } + +backend be_olivetin_http + server olivetinServer 127.0.0.1:1337 check + +backend be_olivetin_webs + timeout tunnel 1h + option http-server-close + server olivetinServer 127.0.0.1:1337 +---- + +The `timeout tunnel 1h` is important: without it, HAProxy's default timeouts will close long-lived websocket connections. See xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets] if you see disconnects. + diff --git a/docs/modules/ROOT/pages/reverse-proxies/intro.adoc b/docs/modules/ROOT/pages/reverse-proxies/intro.adoc new file mode 100644 index 0000000..ac71db4 --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/intro.adoc @@ -0,0 +1,50 @@ +[#reverse-proxies] += Reverse Proxies + +This section of the documentation has a few examples of reverse proxy configurations for popular reverse proxy servers. + +Configuring a reverse proxy server for OliveTin is entirely optional. If you don't want to use a reverse proxy, you can skip this section. + +[#proxy-guide] +== Reverse Proxy general guide + +It's common to put OliveTin behind a reverse proxy, for authentication, customizing the OliveTin address/path, or for a variety of other reasons. + +=== DNS name vs Path based proxies + +DNS Name based virtual hosts (eg: olivetin.example.com ) are easier to setup and configure than path based virtual hosts (eg: www.example.com/utils/OliveTin), because path based virtual hosts need to take care mangle OliveTin paths without breaking things. + +* If using a path based reverse proxies, you may need to set `externalRestAddress` manually to something like; `http://example.com/utils/OliveTin` in the OliveTin config.yaml. +* If using DNS Name based reverse proxies, then you should not need to change anything in config.yaml + +==== Which port? + +If you look at OliveTin startup logs, you will see OliveTin starting services on several ports. For most users, even under reverse proxy configurations, just proxying port 1337 should be all that is needed. To better understand why OliveTin uses several internal ports by default, see xref:reference/network-ports.adoc[network-ports]. + +=== Handling websockets + +OliveTin versions after 2023-08 use websockets instead of polling for updates. Ensure your proxy re-passes the `Connection: Upgrade` and `Upgrade: websocket` headers for the websocket path. In OliveTin 3k the websocket endpoint is `api/olivetin.api.v1.OliveTinApiService/EventStream`; when proxying the whole site (e.g. `ProxyPass /` or `location /`), that path is covered automatically. + +Many reverse proxies use short default timeouts and will close long-lived websocket connections, causing disconnects (OliveTin will reconnect automatically). To avoid this, increase the websocket or proxy timeout in your reverse proxy—see the individual proxy pages (e.g. xref:reverse-proxies/apache.adoc[Apache]) and xref:troubleshooting/err-websocket-connection.adoc[Error Connecting to WebSocket] for details. + +==== General checklist + +* `olivetin.example.com/*` is all just HTTP traffic (port 1337) +** `olivetin.example.com/` should show the standard webui (port 1337) +** `olivetin.example.com/webUiSettings.json` should return a JSON file generated by OliveTin that sets up the web interface. (port 1337) +** `olivetin.example.com/api` should show the REST based API. (port 1337) +* The websocket (e.g. `api/olivetin.api.v1.OliveTinApiService/EventStream` in OliveTin 3k) should be a websocket connection upgrade. + +== What's Next? + +Choose your reverse proxy and follow the configuration guide: + +* xref:reverse-proxies/nginx.adoc[Nginx] - Configure Nginx as a reverse proxy +* xref:reverse-proxies/apache.adoc[Apache] - Configure Apache as a reverse proxy +* xref:reverse-proxies/caddy.adoc[Caddy] - Configure Caddy as a reverse proxy +* xref:reverse-proxies/traefik.adoc[Traefik] - Configure Traefik as a reverse proxy +* xref:reverse-proxies/haproxy.adoc[HAProxy] - Configure HAProxy as a reverse proxy +* xref:reverse-proxies/nginx_proxy_manager.adoc[Nginx Proxy Manager] - Configure NPM as a reverse proxy +* xref:security/trusted_header.adoc[Use trusted headers] - Authenticate users via reverse proxy headers +* xref:reference/network-ports.adoc[Understand network ports] - Learn about OliveTin's port configuration + diff --git a/docs/modules/ROOT/pages/reverse-proxies/nginx.adoc b/docs/modules/ROOT/pages/reverse-proxies/nginx.adoc new file mode 100644 index 0000000..6f09160 --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/nginx.adoc @@ -0,0 +1,42 @@ +[#nginx-dns] += Nginx + +include::partial$reverse-proxies/diagram.adoc[] + +This is an example of DNS based proxying with Nginx. + +./etc/nginx/cond.d/OliveTin.conf +[source,nginx] +---- +include::example$reverse-proxies/etc/reverse_proxy_nginx_dns.conf[] +---- + +Increase the proxy timeout for the websocket location so the reverse proxy does not close long-lived connections; see xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets] if you see disconnects. + + +[#nginx-path] +== Custom paths + +These "custom path" instructions are for when you want to use OliveTin with a custom path like "apps.example.com/olivetin" instead of the root path + DNS name - eg: "olivetin.example.com". Generally it is **not recommended** to use a custom path for OliveTin. Instructions are provided below though, and it mostly-works. + +.nginx.conf +[source,nginx] +---- +.... + location /OliveTin/ { + proxy_pass http://localhost:1337/; + proxy_redirect http://localhost:1337/ http://localhost/OliveTin/; + } + + location /OliveTin/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; + } +.... +---- + +include::partial$reverse-proxies/external-rest.adoc[] + diff --git a/docs/modules/ROOT/pages/reverse-proxies/nginx_proxy_manager.adoc b/docs/modules/ROOT/pages/reverse-proxies/nginx_proxy_manager.adoc new file mode 100644 index 0000000..1c2c9e3 --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/nginx_proxy_manager.adoc @@ -0,0 +1,29 @@ +[#nginx-proxy-manager] += Nginx Proxy Manager + +include::partial$reverse-proxies/diagram.adoc[] + +This is an example of DNS based proxying with Nginx Proxy Manager. + +This example assumes that you are trying to access OliveTin at **olivetin.npm.teratan.lan** and already have a DNS record pointing to the IP address of the Nginx Proxy Manager. This also assumes you are running Nginx Proxy Manager and OliveTin using Docker Compose. + +.docker-compose.yml +[source,yaml] +---- +include::example$reverse-proxies/etc/npm-docker-compose.yml[] +---- + +Note that OliveTin needs a configuration file to run, see the xref:install/docker_compose.adoc[docker compose install instructions] for a bit more detail. + +Assuming you have Nginx Proxy Manager running, start by adding a new proxy host. + +* **Domain Names**: olivetin.npm.teratan.lan (again, assume this is the domain you have set up in your DNS) +* **Scheme**: http (OliveTin does support HTTPS if you create your own certificates, but it is more normal to speak HTTP between Nginx and OliveTin, and just use HTTPS to the proxy). +* **Forward Hostname/IP**: 192.168.66.168 (change this to be the IP address of your docker host) +* **Forward Port**: 1337 (this is the default port for OliveTin) +* **Websockets Support**: Yes (OliveTin uses websockets for the Web UI). If you see websocket disconnects, try increasing the proxy timeout in Nginx Proxy Manager's advanced config; see xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets]. + +That really should be all that you need to get OliveTin working with Nginx Proxy Manager. If you have any issues, please check the logs of both OliveTin and Nginx Proxy Manager for any errors, and look for ways of getting xref:troubleshooting/wheretofindhelp.adoc[support]. + +image::npm.png[] + diff --git a/docs/modules/ROOT/pages/reverse-proxies/traefik.adoc b/docs/modules/ROOT/pages/reverse-proxies/traefik.adoc new file mode 100644 index 0000000..d5a62fb --- /dev/null +++ b/docs/modules/ROOT/pages/reverse-proxies/traefik.adoc @@ -0,0 +1,40 @@ +[#traefik-docker-compose] += Traefik + Docker Compose + +include::partial$reverse-proxies/diagram.adoc[] + +The following example is known to work well with Traefik and docker-compose. If you see websocket disconnects, increase the proxy timeout in Traefik; see xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets]. + +[source,yaml] +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + volumes: + - /docker/olivetin:/config # replace host path or volume as needed + ports: + - "1337:1337" + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.olivetin.entrypoints=web" + - "traefik.http.routers.olivetin.rule=Host(`olivetin.example.com`)" + + traefik: + image: "traefik:v2.9" + container_name: "traefik" + command: + #- "--log.level=DEBUG" + - "--api.insecure=true" + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + - "8080:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" +---- + diff --git a/docs/modules/ROOT/pages/security/acl.adoc b/docs/modules/ROOT/pages/security/acl.adoc new file mode 100644 index 0000000..0ae984e --- /dev/null +++ b/docs/modules/ROOT/pages/security/acl.adoc @@ -0,0 +1,152 @@ +[#acls] += Access Control Lists + +OliveTin uses Access Control Lists (ACLs) to implement it's security model, which allows you to have fine-grained control over indivividual actions or groups of actions. This can be used to implement role based access control (RBAC), or other security models that you may need. + +ACLs are built up of the following set of rules; + +* `name` - The name of the ACL. This is used to identify the ACL in the configuration file. +* `matchUsergroups` - A list of usergroups that this ACL applies to. This is used to match users that are in the specified usergroup. +* `matchUserNames` - A list of usernames that this ACL applies to. This is used to match users that are in the specified usergroup. +* `permissions` - A set of permissions which are used with **actions**. eg: `view`, `exec`, `logs`, etc. +** `addToEveryAction` - A boolean value that indicates if this ACL should be added to every action. This is useful if you want to apply the same ACL to all actions, without having to manually add it to each action. +* `policy` - A policy is a set of rules that affect the **whole of OliveTin**. + +== ACLs and Policies (global) + +[mermaid, "sample", png] +.... +graph TD + A[ACL] --> B[Policy] + A -->|User/UserGroup| C[User/UserGroup] +.... + +**Policies** are a set of rules that apply to the whole of OliveTin ("global"), and not just to individual actions (like permissions are). + +The **defaultPolicy** is special, in that all values are set to true by default. This means that if you do not set a `defaultPolicy`, then all policies will be set to `true` by default. This is effectively what the `defaultPolicy` is set to; + +[source,yaml] +---- +defaultPolicy: + showDiagnostics: true + showLogList: true +---- + +You can override defaults using an ACL, like this; + +[source,yaml] +---- +accessControlLists: + - name: admins + matchUsergroups: + - admins + policy: + showDiagnostics: true + showLogList: true + +defaultPolicy: + showDiagnostics: false + showLogList: false +---- + +== ACLs and Permissions (for Actions) + +[mermaid, "sample", png] +.... +graph TD + A[Action] -->|ACL| B[ACL] + B -->|User/UserGroup| C[User/UserGroup] + B -->|Permissions| D[Permissions] +.... + +An action always starts with `defaultPermissions` (see below), and then then have one or more ACLs applied to it. This means that you can for example have an action that is only available to a certain group of users, or only to a single user. + +Let's say you have a user `james` and a usergroup `admins`. You can then create an ACL that only allows `james` and users in the `admins` group to view and execute an action. + +You can specify default permissions for all actions by changing the `defaultPermissions` like this; + +[source,yaml] +.`config.yaml` +---- +defaultPermissions: + view: false + exec: false + logs: true +---- + +In the example above, all users will start off with the permissions to only see action logs - but will not be able to view or execute actions. + +It is then possible to add an "admins" ACL on top of every action. In the example below, we define one extra ACL called "admins", which matches any users with the usergroup also called "admins". This ACL will then be applied to all actions, and will allow users in the "admins" usergroup to view and execute the action. + +[source,yaml] +.`config.yaml` +---- +defaultPermissions: + view: false + exec: false + +accessControlLists: + - name: admins + matchUsergroups: + - admins + permissions: + view: true + exec: true + +actions: + - title: Shutdown Reactor + acls: + - admins +---- + +=== Add an ACL to every action + +Sometimes you want to define an ACL that applies to all actions. It can be tedious and error prone to manually add the ACL under the "acls" list for every action, if you have several actions. Instead, there is a shortcut to add an ACL to all actions - `addToEveryAction: true`. + +[source,yaml] +.`config.yaml` +```yaml +accessControlLists: + - name: admins + matchUsergroups: + - admins + permissions: + view: true + exec: true + addToEveryAction: true +``` + +== ACL Matching - usernames and usergroups. + +You can match users based on their usergroup which is the most common, but it is also possible to match based on the user's username. + +[source,yaml] +.`config.yaml` +```yaml +accessControlLists: + - name: admins + matchUsergroups: + - admins + permissions: + view: true + exec: true + + - name: james + matchUserNames: + - james + permissions: + view: true + exec: true +``` + +== What's Next? + +Now that you understand ACLs, here's how to implement them: + +* xref:security/examples.adoc[View security examples] - See complete ACL configurations +* xref:security/example_login_required.adoc[Example: Login required] - Configure login requirements +* xref:security/example_some_admin_actions.adoc[Example: Admin-only actions] - Restrict actions to admins +* xref:security/local.adoc[Set up local users] - Create users for ACL matching +* xref:security/oauth2.adoc[Configure OAuth2] - Set up OAuth2 for user groups +* xref:security/design_choices.adoc[Security design recommendations] - Learn best practices for ACL design + diff --git a/docs/modules/ROOT/pages/security/api_keys.adoc b/docs/modules/ROOT/pages/security/api_keys.adoc new file mode 100644 index 0000000..b19b211 --- /dev/null +++ b/docs/modules/ROOT/pages/security/api_keys.adoc @@ -0,0 +1,66 @@ +[#api-keys] += API Keys + +This page is for **developers** who want to call OliveTin's HTTP API (Connect RPC under `/api/`) using a **Bearer token**, without using the interactive web login. + +API keys are configured on xref:security/local.adoc[local users] as an optional `apiKey` field. When present, clients can authenticate by sending: + +---- +Authorization: Bearer +---- + +The prefix `Bearer ` (including the trailing space after `Bearer`) must match exactly. + +== Configuration + +include::partial$config-start.adoc[] +---- +authLocalUsers: + enabled: true + users: + - username: automation + usergroup: bots + apiKey: "{{ .Env.OLIVETIN_AUTOMATION_KEY }}" + + - username: alice + usergroup: admins + password: $argon2id$v=19$m=65536,t=4,p=6$... + apiKey: "{{ .Env.OLIVETIN_ALICE_API_KEY }}" +---- + +* Use a **long, random** API key (similar to any other bearer secret). +* Prefer loading the key from the environment with `{{ .Env.VAR }}` instead of committing the raw value to disk. +* **TLS**: send bearer tokens only over HTTPS in real deployments. +* **Interactive login**: if a user has **no** `password` configured, they **cannot** use the `/login` page; they can only authenticate with an API key (or another auth mechanism you configure separately). + +Two local users **must not** share the same `apiKey` value. OliveTin will refuse to start if duplicate keys are detected. + +== Authorization (permissions) + +API key authentication uses the same **username** and **usergroup** as the matching local user. xref:security/acl.adoc[Access Control Lists] and `defaultPermissions` apply in the same way as for users who sign in via the web UI. + +== Example: curl and Init + +The OliveTin API is **Connect RPC**. Unary calls accept JSON bodies. The following example calls `Init` with an empty request object: + +[source,bash] +---- +curl -sS -X POST \ + -H "Authorization: Bearer YOUR_API_KEY_HERE" \ + -H "Content-Type: application/json" \ + "https://olivetin.example.com:1337/api/olivetin.api.v1.OliveTinApiService/Init" \ + --data '{}' +---- + +Replace the host, port, and path prefix if your installation differs. Other RPCs use the same URL pattern with a different final segment (method name). + +== Operational security notes + +* **Reverse proxies**: if you use xref:security/trusted_header.adoc[Trusted Header Authorization], remember it is evaluated **before** bearer API keys. Do not expose OliveTin in a way that allows clients to spoof trusted identity headers. +* **Debug logging**: avoid enabling `logDebugOptions.singleFrontendRequestHeaders` in production. OliveTin redacts common sensitive headers (including `Authorization`) in debug output, but minimizing debug surface area is still recommended. +* **Brute force**: OliveTin does not ship per-IP rate limiting for failed bearer attempts. Consider rate limiting or WAF rules on `/api/` at your reverse proxy. + +== See also + +* xref:security/local.adoc[Local Users Authorization] (password hashing and local user basics) +* xref:security/acl.adoc[Access Control Lists] diff --git a/docs/modules/ROOT/pages/security/concepts.adoc b/docs/modules/ROOT/pages/security/concepts.adoc new file mode 100644 index 0000000..d66dc58 --- /dev/null +++ b/docs/modules/ROOT/pages/security/concepts.adoc @@ -0,0 +1,34 @@ +[#auth-concepts] += Security Concepts + +OliveTin implements a security model that covers **Authentication**, **Authorization** (via xref:security/acl.adoc[ACLs]) and **Accounting**. + +== Authentication + +To allow users to be Authenticated to OliveTin, there are several options to choose from; + +- xref:security/local.adoc[Local Users] (ie: Login with Username and Password) +- xref:security/oauth2.adoc[OAuth2] (eg: Google, GitHub, etc) +- xref:security/trusted_header.adoc[Trusted Header] (eg: Nginx, Apache, etc) +- xref:security/jwt.adoc[JWT] (eg: Traefik, Organizr, etc) + +== Authorization + +OliveTin's authorization system, or permissions, is built on xref:security/acl.adoc[Access Control Lists]. This is a powerful mechanism that allows you to implement very fine grained access control, or your own role based access control (RBAC). + +== Accounting + +OliveTin's accounting is via it's logs. This aspect of OliveTin's security model is poorly documented at the moment. + +== What's Next? + +Now that you understand OliveTin's security model, implement it for your use case: + +* xref:security/local.adoc[Set up local users] - Configure username/password authentication +* xref:security/oauth2.adoc[Configure OAuth2] - Integrate with OAuth2 providers (Google, GitHub, etc.) +* xref:security/trusted_header.adoc[Use trusted headers] - Authenticate via reverse proxy headers +* xref:security/jwt.adoc[Configure JWT] - Use JWT tokens for authentication +* xref:security/acl.adoc[Set up Access Control Lists] - Implement fine-grained permissions +* xref:security/examples.adoc[View security examples] - See complete security configurations +* xref:security/design_choices.adoc[Security design recommendations] - Learn best practices for securing OliveTin + diff --git a/docs/modules/ROOT/pages/security/content_security_policy.adoc b/docs/modules/ROOT/pages/security/content_security_policy.adoc new file mode 100644 index 0000000..fe9a393 --- /dev/null +++ b/docs/modules/ROOT/pages/security/content_security_policy.adoc @@ -0,0 +1,54 @@ +[#content-security-policy] += Content Security Policy (CSP) + +When xref:reference/network-ports.adoc[the single HTTP frontend] is enabled (the default), OliveTin adds several browser security headers to every response, including `Content-Security-Policy`. This page explains how to turn that header off or replace it with a less strict policy when you need to (for example, custom scripts, different API hosts, or embedding in an iframe). + +[WARNING] +Relaxing or removing CSP weakens protection against cross-site scripting and related attacks. Prefer the smallest change that fixes your issue, and keep the rest of the policy as tight as you can. + +== Default behavior + +With default settings, OliveTin sends a `Content-Security-Policy` header similar to the following (single line in the actual response): + +[source,text] +---- +default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self' +---- + +If `security.headerContentSecurityPolicy` is `true` but `security.contentSecurityPolicy` is left empty, OliveTin fills in this default on startup. + +== Disable the CSP header entirely + +Set `security.headerContentSecurityPolicy` to `false`. OliveTin will not send `Content-Security-Policy` on responses from the single HTTP frontend. + +include::partial$config-start.adoc[] +---- +security: + headerContentSecurityPolicy: false +---- + +== Use a custom (relaxed) policy + +Keep `security.headerContentSecurityPolicy` `true` and set `security.contentSecurityPolicy` to the full header value you want. For example, to allow WebSocket connections to the same host when you serve the UI over plain HTTP in a lab (not recommended for production), you might widen `connect-src`: + +include::partial$config-start.adoc[] +---- +security: + headerContentSecurityPolicy: true + contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http: https: ws: wss:; frame-ancestors 'none'; base-uri 'self'" +---- + +Build your policy from the https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP[MDN documentation on CSP] and test in the browser developer tools (Console will report CSP violations). + +Typical reasons people adjust this setting: + +* **Custom JavaScript or third-party scripts** — You may need to extend `script-src` (and sometimes `connect-src` for XHR/fetch). See xref:advanced_configuration/webui.adoc#custom-js[Custom JavaScript]. +* **Embedding OliveTin in another site** — The default includes `frame-ancestors 'none'`, which blocks iframes. You must change that directive (and may need to relax `X-Frame-Options` using `security.headerXFrameOptions` and `security.xFrameOptions`) if embedding is required. +* **API or auth on another origin** — Extend `connect-src` (and possibly `form-action` or others) to include those URLs. +* **Reverse proxy also sets CSP** — Your proxy might add a second policy; browsers combine them. Align OliveTin and the proxy so you do not get conflicting or unexpectedly strict effective policies. + +== Related settings + +Other keys under `security` in `config.yaml` control additional headers (for example `headerXContentTypeOptions`, `headerXFrameOptions`, and `xFrameOptions`). This page focuses on CSP; see the OliveTin `SecurityConfig` in the application source if you need the full list of fields. + +Configuration reload: if you use OliveTin's live config reload, changes to `security` are picked up without restarting the process in typical setups. diff --git a/docs/modules/ROOT/pages/security/design_choices.adoc b/docs/modules/ROOT/pages/security/design_choices.adoc new file mode 100644 index 0000000..e411074 --- /dev/null +++ b/docs/modules/ROOT/pages/security/design_choices.adoc @@ -0,0 +1,21 @@ += Security Design & Hardening Recommendations + +[WARNING] +OliveTin has not *yet* had a remote code execution vulnerability found in it. However, given what OliveTin does, it is possible, and likely that a security vulnerability will be found in the future. This document explains how OliveTin is designed so that you can make an informed decision about how to use OliveTin in your environment. It also provides hardening recommendations to help you secure OliveTin. + +== Security Design + +OliveTin has a few design choices that should help it's general security posture. + +* OliveTin does deliberate not have any web based control panel where commands can be typed in. This is try to avoid arbitary command execution vulnerabilities caused by authentication bypass attacks. +* Control over what commands are run is determined via the `config.yaml` alone. OliveTin does NOT write to the config.yaml in any way. This is to avoid any of arbitary command execution vulnerabilities caused by writing to the config.yaml. +* OliveTin listens on just 1 open public port by default (1337). The rest of the ports only listen on `localhost` so you don't have to worry about them in your firewall. +* Standard Linux controls can be used to run OliveTin as non-root, with `sudo` permissions if needed. See the action customization section of these docs for more details. +* Robust code-scanning, code review, and dependency analysis at build-time. OliveTin uses many linters and code checkers, especially on new pull requests. Out dated dependencies are addressed quickly. + +== Hardening Recommendations + +* Implement authentication on the OliveTin API using one of the many methods provided. +* Run OliveTin as a non-root user, or even better, run OliveTin in a container (as non-root). +* Use normal `sudo` permissions to elevate OliveTin to run privileged commands, and restrict the commands that OliveTin can run with `sudo` to only the ones you need. +* Place a reverse proxy in front of OliveTin, or better, a web application firewall. diff --git a/docs/modules/ROOT/pages/security/example_login_required.adoc b/docs/modules/ROOT/pages/security/example_login_required.adoc new file mode 100644 index 0000000..4036493 --- /dev/null +++ b/docs/modules/ROOT/pages/security/example_login_required.adoc @@ -0,0 +1,61 @@ +[#example-login-required] += Example: Force Login + +A common use case for OliveTin with security is to expose some dashboards that require login to be able to use. This page brings together the configuration options that are needed to achieve this. The most important configuration option is setting `authRequireGuestsToLogin` to `true`. + +== Full example configuration + +```yaml +logLevel: "INFO" + +authRequireGuestsToLogin: true + +accessControlLists: + - name: "admins" + permissions: + view: true + exec: true + logs: true + matchUsergroups: + - "admins" + addToEveryAction: true + + +authLocalUsers: + enabled: true + users: + - username: "admin" + usergroup: admins + password: -- your password hash here -- + +actions: + - title: "Restart" + shell: echo "Restart" + +dashboards: + - title: "Admin Dashboard" + contents: + - title: "Restart" +``` + +Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section. + +== Important configuration option: `AuthRequireGuestsToLogin` + +The `AuthRequireGuestsToLogin` option is a helpful shortcut that sets all `defaultPermissions` to false, and makes it so that all guests are prompted to login before they can do anything with OliveTin. + +Technically, you could achieve the same effect by setting `defaultPermissions` to `false` and setting up an ACL that allows access to the login page, but `AuthRequireGuestsToLogin` is a more convenient way to achieve the same effect. + +== Per-action ACLs, vs `addToEveryAction` + +It is possible to specify one or more ACL per action, like so; + +```yaml +actions: + - title: "Restart" + shell: echo "Restart" + acl: + - name: "admins" +``` + +However, this configuration is also a bit more verbose, and if you just have one main ACL, can save yourself some typing by using the `addToEveryAction` option in the ACL configuration. diff --git a/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc b/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc new file mode 100644 index 0000000..8d11086 --- /dev/null +++ b/docs/modules/ROOT/pages/security/example_some_admin_actions.adoc @@ -0,0 +1,96 @@ += Example: Some actions require admin + +A common use case for OliveTin with security is to expose some actions to guests, and have some actions that require login to be able to use. This page brings together the configuration options that are needed to achieve this. + +== How ACL permissions work + +OliveTin ACLs are *allow lists*, not deny lists. Each action starts with `defaultPermissions`, and then any ACLs listed on that action can *grant* access for matching users. An ACL with `view: false` does not deny access — it simply does not grant it. If no relevant ACL grants a permission, OliveTin falls back to `defaultPermissions`. + +The default `defaultPermissions` allow guests to view and execute every action. To restrict some actions to logged-in admins while leaving others open to guests, set `defaultPermissions` to deny access by default, then use ACLs to explicitly grant access on each action. + +See xref:security/acl.adoc[Access Control Lists] for the full ACL reference. + +== Full example configuration + +```yaml +logLevel: "INFO" + +defaultPermissions: + view: false + exec: false + logs: false + +accessControlLists: + - name: "guests" + permissions: + view: true + exec: true + logs: false + matchUsernames: [ "guest" ] + + - name: "admins" + permissions: + view: true + exec: true + logs: true + matchUsergroups: [ "admins" ] + +authLocalUsers: + enabled: true + users: + - username: "admin" + usergroup: admins + password: -- your password hash here -- + +actions: + - title: "Date" + shell: date + acls: + - "guests" + + - title: "Reboot" + shell: reboot # Note that this won't work inside a container + acls: + - "admins" + +dashboards: + - title: "Guest Dashboard" + contents: + - title: "Date" + + - title: "Admin Dashboard" + contents: + - title: "Reboot" +``` + +Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section. + +With this configuration: + +* Guests (not logged in) can view and run the *Date* action only. +* Logged-in users in the `admins` usergroup can view and run the *Reboot* action. +* Guests are not forced to log in — they simply do not see or cannot run actions that only list the `admins` ACL. + +== Common mistake: using a deny ACL for guests + +A configuration like the one below does *not* work as a deny rule when guests are allowed to browse without logging in: + +[source,yaml] +---- +accessControlLists: + - name: "noguests" + permissions: + view: false + exec: false + matchUsernames: [ "guest" ] + +actions: + - title: "Reboot" + acls: + - "noguests" + - "admins" +---- + +Because `view: false` does not deny access, guests still fall back to `defaultPermissions` (which default to `true`) and can see the action. Setting `authRequireGuestsToLogin: true` makes that pattern appear to work, but only because it forces all guests to log in first and sets all `defaultPermissions` to `false`. If you need mixed guest and admin access without forcing login, use the allow-list pattern in the full example above instead. + +If you want *every* action to require login, see xref:security/example_login_required.adoc[Example: Force Login]. diff --git a/docs/modules/ROOT/pages/security/examples.adoc b/docs/modules/ROOT/pages/security/examples.adoc new file mode 100644 index 0000000..0863297 --- /dev/null +++ b/docs/modules/ROOT/pages/security/examples.adoc @@ -0,0 +1,10 @@ += Security Examples + +The following examples show you how to combine several security configuration options to setup common scenarios that people often ask for. + +* xref:security/example_login_required.adoc[Example: Login Required] +* xref:security/example_some_admin_actions.adoc[Example: Some actions required admin] + +== Security Solutions + +* xref:solutions/cloudflare_access_tunnel/index.adoc[] diff --git a/docs/modules/ROOT/pages/security/jwt.adoc b/docs/modules/ROOT/pages/security/jwt.adoc new file mode 100644 index 0000000..9afcec8 --- /dev/null +++ b/docs/modules/ROOT/pages/security/jwt.adoc @@ -0,0 +1,22 @@ +[#jwt] += JWT Authorization + +One of the best ways to do authorization with OliveTin is to pass it a **JSON Web token (JWT)**, after first authenticating with a popular single sign on system, like Keycloak, CloudFlare Tunnels, Authentik or Organizr. + +Two types of JWT mechanisms are supported; + +* xref:security/jwt_keys.adoc[JWT with Keys] (eg: CloudFlare Tunnels, Authentik) +** X509 Certs/Keys on disk are supported +** **JWKS** is also supported +* xref:security/jwt_hmac.adoc[JWT with HMAC] (eg: Organizr) + +== JWT Flow + +The flow generally goes like this; + +1. User browses to a website like Organizr and logs in, which sets a JWT Cookie for apps.example.com. +2. User browses to OliveTin.apps.example.com, and the cookie is sent to OliveTin. +3. OliveTin verifies the JWT token given the signing secret, and picks up on the `name` and `group` fields from the JWT claim. +4. OliveTin matches any relevant ACLs based on the claims. +5. If any ACLs are not matched, then the defaultPermissions are used. + diff --git a/docs/modules/ROOT/pages/security/jwt_hmac.adoc b/docs/modules/ROOT/pages/security/jwt_hmac.adoc new file mode 100644 index 0000000..ca3a693 --- /dev/null +++ b/docs/modules/ROOT/pages/security/jwt_hmac.adoc @@ -0,0 +1,100 @@ +[#jwt-hmac] += JWT with HMAC + +You need to know your JWT **Cookie Name** and **Hash Secret**. Whatever tool you are using to authenticate users will probably have instructions on how to find this. + +* link:https://docs.organizr.app/features/server-authentication#validating-the-token[Organizr - Under "Validating the token"] + + Adding JWT details to OliveTin config.yaml + +Setup your config file so it has something like this; + +[source,yaml] +.`config.yaml` +---- +# It's often useful to turn logging to DEBUG when trying to work out authentication problems +logLevel: "INFO" + +authJwtCookieName: "Organizr_token_1234..." +authJwtHmacSecret: "3l4jh23v_123!" +authJwtClaimUsername: "username" +authJwtClaimUsergroup: "usergroup" +---- + +Note that your `authJwtCookieName` and `authJwtSecret` will need to be set exactly as they appear in your Authentication software. + +== Usable claims + +OliveTin currently can match Access Control Lists based on a **username** or **user group(s)**. You can see if these are being used properly turning on `DEBUG` logging and looking at the jwt claims. + +If `authJwtClaimUsergroup` is any array, ACL groups will match any of the user groups in the array. + +== Setup default permissions + +OliveTin will assume that guests are able to View and Execute every action by default. When you are setting up authorization you probably want to limit this. You can do that by setting `defaultPermissions` like this; + +[source,yaml] +.`config.yaml` +---- +logLevel: "INFO" + +defaultPermissions: + view: false + exec: false +---- + +== Setup OliveTin Access Control Lists + +Access Control Lists are a way to override the default permissions. + +[source,yaml] +.`config.yaml` +---- +logLevel: "INFO" + +defaultPermissions: + view: false + exec: false + logs: true + +accessControlLists: + - name: Admins + addToEveryAction: true + matchUsergroups: + - Admins + permissions: + view: true + exec: true + logs: true + + - name: "Developers" + matchUsergroups: + - "developer" + permissions: + view: true + exec: false + logs: false + +actions: + - name: Only visible to admins + shell: echo "I am a secret command only visible to admins" + + - name: Restart database + shell: systemctl restart mariadb + acls: + - "developer" +---- + +In the example above, the `admins` ACL is automatically added to every action, because `addToEveryAction` is true. + + Customizing field names + +You may need to customize the field names for your JWT authentication. + +[source,yaml] +.`config.yaml` +---- +authJwtClaimUsername: "username" +authJwtClaimUsergroup: "usergroup" +---- + diff --git a/docs/modules/ROOT/pages/security/jwt_keys.adoc b/docs/modules/ROOT/pages/security/jwt_keys.adoc new file mode 100644 index 0000000..f1b4b46 --- /dev/null +++ b/docs/modules/ROOT/pages/security/jwt_keys.adoc @@ -0,0 +1,51 @@ +[#jwt-keys] += JWT with Keys + +include::partial$earlydoc.adoc[] + +== Using Public Keys via JWKS + +OliveTin Supports **JSON Web Key Sets (JWKS)**. This approach is often used with services like CloudFlare. + +[source,yaml] +.`config.yaml` +---- +authJwtAud: "asdf1234" +authJwtCertsURL: "https://mydomain.cloudflareaccess.com/cdn-cgi/access/certs" +authJwtClaimUsername: email +authJwtCookieName: "CF_Authorization" +---- + +You may well want to set `logLevel: DEBUG` and `insecureAllowDumpJwtClaims: true` in your config when testing JWT for the first time. + +== Using with Teleport/Headers + +If you are using Teleport, you can use the `authJwtCertsURL` to point to the Teleport JWKS. + +Teleport can only https://goteleport.com/docs/enroll-resources/application-access/jwt/introduction/#inject-jwt[inject the JWT into a header], so you will need to set `authJwtHeader` to the header name that you have configured Teleport to use, e.g., `Authorization`. + + +[source,yaml] +.`config.yaml` +---- +authJwtCertsURL: "https://teleport.mydomain/.well-known/jwks.json" +authJwtHeader: Authorization +---- + +Replace teleport.mydomain with the domain of your Teleport instance. + +== Using Public Keys on Disk + +This approach can be useful if your Authentication service does not support JWKS, or if you don't want to use it. Public Keys should be available on disk in a file - which can have any filename or extension you like. The files need to be RSA keys in PEM format to be used by OliveTin, though. P12 is not supported. +[source,yaml] +.`config.yaml` +---- +authJwtAud: "asdf1234" +authJwtPubKeyPath: "/opt/mykey.crt" +authJwtClaimUsername: email +authJwtCookieName: "CF_Authorization" +---- + +== See Also + +* xref:solutions/cloudflare_access_tunnel/index.adoc[Cloudflare Access & Tunnels Solution] diff --git a/docs/modules/ROOT/pages/security/local.adoc b/docs/modules/ROOT/pages/security/local.adoc new file mode 100644 index 0000000..a6634e9 --- /dev/null +++ b/docs/modules/ROOT/pages/security/local.adoc @@ -0,0 +1,96 @@ +[#local-users] += Local Users Login + +OliveTin supports just basic users defined with a username and password in the config.yaml file. This can be used when you do not want to use a full authentication system like LDAP, OAuth2 or a Reverse Proxy. + +For programmatic access (scripts, integrations) using per-user bearer API keys, see xref:security/api_keys.adoc[API Keys]. + +== Define a user + +include::partial$config-start.adoc[] +---- +authLocalUsers: + enabled: true + users: + - username: james + password: $argon2id$v=19$m=65536,t=4,p=6$LnNW4sw+jZfa5Ex3YjfuHQ$vl8pjUJhxNmBxScV4lI3cgAZPkNB1rSrnX6ibgoAP8k +---- + +== Define users with a user group + +OliveTin local users do not need to be part of a user group, and unless any user groups are added, they will not be in any user group. However, if you want to add a user to a user group, you can do so like this: + +include::partial$config-start.adoc[] +---- +authLocalUsers: + enabled: true + users: + - username: alice + usergroup: admins + password: $argon2id$v=19$m=65536,t=4,p=6$LnNW4sw+jZfa5Ex3YjfuHQ$vl8pjUJhxNmBxScV4lI3cgAZPkNB1rSrnX6ibgoAP8k + + - username: bob + password: ... + usergroup: admins + + - username: charlie + password: ... + usergroup: webmasters +---- + +== Get a Argon2id hashed password + +You will notice from the configuration examples above that the password is hashed using Argon2id. You can use any of the following methods to generate a Argon2id hashed password; + +=== Option A - Using OliveTin API + +You can see from the example above that the config contains a single user called *james*, and the password is hashed using Argon2id. OliveTin provides a utility API to hash passwords using Argon2id which can be useful when you want to create new users. Simply run the following curl command to hash a password: + +```bash +curl -sS --json '{"password": "myPassword"}' http://olivetin.example.com:1337/api/PasswordHash +``` + +NOTE: Curl 7.82 added support for the `--json` option, if you are using an older version of curl, see link:https://github.com/OliveTin/OliveTin/issues/462[this issue]. + +This will return a output like this, you can then copy and paste this hash into your config.yaml file; + +``` +Your password hash is: $argon2id$v=19$m=65536,t=4,p=6$dlWTV1RL04/Nuvxzl94NAg$KsYXvCFE2Eu/jkXi/dbbZM3I/2b2VByTAwRIenUwdJk +``` + +=== Option B - Using the `argon2` command line tool + +You can also easily hash the password using the `argon2` package: + +```bash +echo -n "myPassword" | argon2 "$(openssl rand -base64 16)" -id -t 4 -m 16 -p 6 -l 32 -e +``` + +=== Opption C - Using the `hash` docker image +Or using the link:https://hub.docker.com/r/leplusorg/hash[hash] docker image: + +```bash +docker run --rm -i --net=none leplusorg/hash sh -c 'echo -n "myPassword" | argon2 "$(openssl rand -base64 16)" -id -t 4 -m 16 -p 6 -l 32 -e' +``` + +Then simply visit the OliveTin web interface and browse to the login page, eg: http://olivetin.example.com:1337/login + +=== Why does OliveTin use Argon2id? +Argon2id is the password hashing algorithm that is link:https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html[recommended by OWASP] as of October 2024. There doesn't seem to be a good reason yet to provide configuration options for changing the password hashing algorithm, but if you have a good reason, please open an issue on the GitHub repository. + + +== Force login page + +If you don't want to allow guests to do anything in OliveTin, you can use the `authRequireGuestsToLogin` option to force all users to login before they do anything. This will redirect all users to the login page if they are not logged in, and it will also set `defaultPermissions` to `false`, meaning that permissions must be explicitly set for each user or user group. + +include::partial$config-start.adoc[] +---- +authRequireGuestsToLogin: true + +authLocalUsers: + enabled: true + users: + - username: james + password: $argon2id$v=19$m=65536,t=4,p=6$LnNW4sw+jZfa5Ex3YjfuHQ$vl8pjUJhxNmBxScV4lI3cgAZPkNB1rSrnX6ibgoAP8k +---- + diff --git a/docs/modules/ROOT/pages/security/oauth2.adoc b/docs/modules/ROOT/pages/security/oauth2.adoc new file mode 100644 index 0000000..b07c338 --- /dev/null +++ b/docs/modules/ROOT/pages/security/oauth2.adoc @@ -0,0 +1,106 @@ +[#oauth2] += OAuth2 + +include::partial$earlydoc.adoc[] + +OliveTin supports OAuth2 for login with any OAuth2 compliant provider. + +At the moment, username fetching is only supported on GitHub. More will be added soon, probably with the addition of OpenID Connect support. + +```yaml +authOAuth2RedirectUrl: http://localhost:1337/oauth/callback +authOAuth2Providers: + github: + clientId: 1234567890 + clientSecret: 1234567890 +``` + +== Provider configuration + +* `name` - a "simple name" for the provider, used in the login redirect and internally in OliveTin, e.g. `github` +* `title` - the human-readable name of the provider, e.g. `GitHub` +* `clientId` - the client ID provided by the OAuth2 provider +* `clientSecret` - the client secret provided by the OAuth2 provider +* `icon` - the icon to use for the provider. Accepts any HTML, e.g. `` +* `scopes` - a list of scopes to request. +* `authUrl` - the URL to redirect to for authentication +* `tokenUrl` - the URL to exchange the code for a token +* `whoamiUrl` - the URL to fetch user information from +* `usernameField` - the field in the user information response to use as the username +* `userGroupField` - the field in the user information response to use as the group. This is a string containing one group name, e.g. `olivetin_group` +* `addToUsergroup` - a group name to add to every user who logs in via this provider. If the user already has a usergroup (e.g. from `userGroupField`), this value is appended to it; otherwise it becomes the user's usergroup. Useful for giving all users from this provider a common group for ACLs, e.g. `addToUsergroup: github` +* `certBundlePath` - the path to a certificate to add to the truststore for authentication requests, e.g. `/certs/internal.crt` +* `insecureSkipVerify` - a boolean to disable certificate verfication +* `connectTimeout` - an integer for seconds until the request will timeout, e.g. `10` + +== Built-in providers (`name`) + +OliveTin comes with a few built-in providers for convenience. If you are using one of these with a `name`, then you don't need to specify the various URLs, scopes, icon, usernameField, etc. It will be automatically configured for you. You will still need to provide the client ID and client secret. + +* `github` - GitHub +* `google` - Google + +== AddToUsergroup examples + +The `addToUsergroup` option assigns a group to every user who signs in through that OAuth2 provider. You can use it alone, or together with `userGroupField`, so that all users from the provider share a common group for ACLs (e.g. "everyone from GitHub") while still keeping provider-specific groups when available. + +=== When the provider does not return a group + +Some providers (e.g. Google with standard Gmail accounts) do not return a group claim. Users logging in through them get no group, so they do not match any ACLs and end up with no permissions or visible actions. Use `addToUsergroup` to give every user from that provider a default group such as `guest`: + +```yaml +authOAuth2Providers: + google: + clientId: your-client-id + clientSecret: your-client-secret + addToUsergroup: guest +``` + +Then ensure an ACL matches that group, for example `matchUsergroups: ["guest"]`, so those users get the intended access. + +=== All users from a provider in one group + +Give every user who logs in via GitHub the group `github` so you can target them in ACLs: + +```yaml +authOAuth2Providers: + github: + clientId: your-client-id + clientSecret: your-client-secret + addToUsergroup: github +``` + +Then in your actions you can restrict access with `allowedUserGroups: ["github"]`. + +=== Combining with userGroupField + +If your provider returns a group (e.g. from GitHub org/team or an IdP), use both `userGroupField` and `addToUsergroup`. The provider group is used as the user's group, and `addToUsergroup` is appended so the user belongs to both: + +```yaml +authOAuth2Providers: + github: + clientId: your-client-id + clientSecret: your-client-secret + userGroupField: olivetin_group + addToUsergroup: github +``` + +A user with `olivetin_group: admins` will end up in groups `admins` and `github`; a user with no group will get only `github`. + +=== Multiple providers, shared and per-provider groups + +Use different `addToUsergroup` values per provider so you can allow "all OAuth users" or "only GitHub" / "only Google": + +```yaml +authOAuth2Providers: + github: + clientId: github-client-id + clientSecret: github-client-secret + addToUsergroup: github + google: + clientId: google-client-id + clientSecret: google-client-secret + addToUsergroup: google +``` + +Then use ACLs such as `allowedUserGroups: ["github"]` for GitHub-only actions or `allowedUserGroups: ["github", "google"]` for any OAuth user. diff --git a/docs/modules/ROOT/pages/security/oauth2_authelia.adoc b/docs/modules/ROOT/pages/security/oauth2_authelia.adoc new file mode 100644 index 0000000..2e39adb --- /dev/null +++ b/docs/modules/ROOT/pages/security/oauth2_authelia.adoc @@ -0,0 +1,79 @@ += OAuth2 - Authelia + +Notes contributed by a member of the OliveTin community - many thanks Phampyk! + +[source,yaml] +.Authelia code +---- +identity_providers: + oidc: + hmac_secret: "xxxxxx" + + jwks: + - key_id: "primary" + algorithm: "RS256" + use: "sig" + key: | + -----BEGIN PRIVATE KEY----- + xxxxxxxxxxxxxxxxxxxxxxxxxx + -----END PRIVATE KEY----- + + clients: + - client_id: "olivetin" + client_name: "OliveTin" + client_secret: "xxxxxxxxxxxxxxxxx" + redirect_uris: + - "https://olivetin.hostname.com/oauth/callback" + scopes: + - openid + - profile + consent_mode: implicit +---- + +* hmac_secret generated with `openssl rand -hex 64 or can be authelia crypto rand --length 64 --charset alphanumeric` link:https://www.authelia.com/reference/guides/generating-secure-values/#generating-a-random-alphanumeric-string[Source] +* Private key generated with `openssl genrsa -out oidc.key 2048 and openssl rsa -in oidc.key -pubout -out oidc.pub` but only used the oidc.key here +* client_id olivetin is for the example, as per authelia docs the recomendation is a random string generated with `authelia authelia crypto rand --length 72 --charset rfc3986` link:https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#client-id--identifier[Source] +* consent_mode I had to set this one up as implicit or every time I loged in it was an extra step where you had to authorize OliveTin to access profile and openid. link:https://www.authelia.com/configuration/identity-providers/openid-connect/clients/#consent_mode[Source] +* client_secret is recommended in the docs to be generated with `authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986` and it gives you the password (Random password on the example) and the hash (Digest on the example). Olivetin needs the password and Authelia the hash link:https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#client-secret[Source] + +---- +Random Password: JxMbHrQgmykaVm2n0p_5q6P_YoZG_YdRWvHxHbVJ5Alv.Ni3OJPVPHEJ6Tfw_AklrwayFl39 +Digest: $pbkdf2-sha512$310000$yQogpMZvkHoAmOBGiIHVJQ$hxKuvar6Q6pOlkdzQBMWq1i5WjXcBA3rvuXxeylvLeTuKI/hLVeZsM43R5TWejZ6gBp/OH8yy1hWytiohLQh5w +---- + +== OliveTin config + +[source,yaml] +---- +authRequireGuestsToLogin: true +authOAuth2RedirectURL: https://olivetin.hostname.com/oauth/callback +authOAuth2Providers: + authelia: + name: authelia + title: Authelia + clientID: olivetin #same as authelia + clientSecret: xxxxxxx #same as authelia but not hashed + authURL: https://authelia.hostname.com/api/oidc/authorization + tokenURL: https://authelia.hostname.com/api/oidc/token + whoamiUrl: https://authelia.hostname.com/api/oidc/userinfo + scopes: + - openid + - profile + usernameField: preferred_username + icon: + +accessControlLists: + - name: john #same as authelia + matchUserNames: + - john + permissions: + view: true + exec: true + logs: true + addToEveryAction: true +---- + +== Next steps + +Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page. + diff --git a/docs/modules/ROOT/pages/security/oauth2_authentik.adoc b/docs/modules/ROOT/pages/security/oauth2_authentik.adoc new file mode 100644 index 0000000..03459d5 --- /dev/null +++ b/docs/modules/ROOT/pages/security/oauth2_authentik.adoc @@ -0,0 +1,203 @@ +[#oauth2-authentik] += OAuth2 - Authentik + +OliveTin has been tested with Authentik. This documentation page describes how to configure Authentik for use with OliveTin and assumes you already have Authentik installed and running. + +Login as an Authentik administrator and start by creating a new app as follows; + +image::authentik_new_app.png[] + +Click Next, and on the **Provider Type** page select **OAuth2**. + +image::authentik_select_oauth2.png[] + +Click Next, and on the **Provider Configuration** page, fill in the following fields; + +* **Authorization flow:** `default-authorization-eplicit-consent (Authorize Application)` (or similar) +* **Client Type**: `confidential` - OliveTin requires a confidential client (it keeps it's secrets on the server side). + +image::authentik_provider_config.png[] + +Scroll down, and on the same page, copy the **Client ID** and **Client Secret** fields into a text file, a secret manager, or somewhere else safe. You will need these values later. These are used in the OliveTin configuration file later. + +For the **Redirect URIs**, OliveTin requires that the URI ends with `/oauth/callback`. Therefore if your OliveTin instance is running on `http://example.com:1337`, you should add the following redirect URI: `http://example.com:1337/oauth/callback`. Note that the URL must match the URL that you use to access OliveTin - so it is whatever you type in your browser address bar, with `/oauth/callback` appended to it. + +[NOTE] +That URL says `oauth`, not `oauth2`. OliveTin only supports OAuth2, not "OAuth[1]", but the path is `oauth` nevertheless. + +image::authentik_provider_secrets.png[] + +If your Authentik instance has a "Configure Bindings" page, you can bind users to be able to access OliveTin like you would with any other application that you add to Authentik. + +Submit this wizard to save the configuration. + +== Group Mapping + +OliveTin `2024.11.24` added support for OAuth2 group mapping for a single group. OliveTin `2025.7.29` added support for OAuth2 group mapping for multiple groups when passed as a commaa-separated list. + +The examples below show various ways to map groups from Authentik to OliveTin. + +=== Multiple group mapping: Comma-separated list + +The below will match all groups the user is a member of and return them as a comma-separated list. If no groups are found then an empty string is returned (no groups)". + +In Authentik: `Admin Interface > Customization > Property Mappings > Create > Scope Mapping` + +* **Name**: `olivetin-group-mapping-multiple` (or similar) +* **Scope Name**: `olivetin-group-mapping-multiple` (or similar) +* **Description**: `map all groups to a comma-separated list for olivetin` +* **Expression**: + +[source,python] +.Multiple group mapping: Comma-separated list +---- +groups = [group.name for group in user.ak_groups.all()] + +return { + "olivetin_group_list": ",".join(groups) +} +---- + + +=== Single group mapping: First Prefix Match + +The below will match the first group the user is a member of that matches the prefix defined in `group_prefix`, which is set to `olivetin`. If no match is found, the group `guest` is returned by default. Both `group_prefix` and `returned_group` can be changed to your needs. + +In Authentik: `Admin Interface > Customization > Property Mappings > Create > Scope Mapping` + +* **Name**: `olivetin-group-mapping` +* **Scope Name**: `olivetin-group-mapping` +* **Description**: `map first group that starts with "olivetin"` +* **Expression**: + +[source,python] +.Single group mapping: First Prefix Match +---- +group_prefix = "olivetin" +returned_group = "guest" + +groups = [group.name for group in user.ak_groups.all()] + +for group in groups: + if group.startswith(group_prefix): + returned_group = group + break + +return { + "olivetin_group_first": returned_group +} +---- + +[IMPORTANT] +If you use this multiple group mapping, you will need to set the `AuthHttpHeaderUserGroupSep` field to `,`. This may sound like a strangely named field, but it is the correct one to use for this mapping. It was originally created for the HTTP Trusted Header authentication method, but it is also used for OAuth2 group mapping. + +=== Single group mapping: Specific Group Match + +The below will match the specified group name to one of the groups the user is a member of. If no match is found, the group `guest` is returned by default. Both `olivetin_group` and `returned_group` can be changed to your needs. + +In Authentik: `Admin Interface > Customization > Property Mappings > Create > Scope Mapping` + +* **Name**: `olivetin-group-mapping-specific` +* **Scope Name**: `olivetin-group-mapping-specific` +* **Description**: `search and map specified group for olivetin` +* **Expression**: + +[source,python] +.Single group mapping: Specific Group Match +---- +olivetin_group = "olivetin-users" +returned_group = "guest" + +groups = [group.name for group in user.ak_groups.all()] + +if olivetin_group in groups: + returned_group = olivetin_group + +return { + "olivetin_group_specific": returned_group +} +---- + +=== Enable Group Mapping + +After creating the scope mapping in Authentik, you will need to add it to your provider. For the your OliveTin config, use the `userGroupField` mentioned in the following section. + +In Authentik: `Admin Interface > Applications > Providers > {Your Provider} > Edit` + +* Open `Advanced protocol settings` +* Under `Scopes`, add `your_scope_map` to `Selected Scopes` +* Click `Update` + +== OliveTin configuration + +This section assumes that your authentik server is accessible in the browser at `http://localhost:9000` and that OliveTin is running on `http://localhost:1337`. Adjust the URLs as necessary to match your setup. The "path" part of the URL is important and should be common in all Authentik installations. + +The necessary OliveTin configuration is as follows: + +[source,yaml] +---- +authRequireGuestsToLogin: true # Optional - depends if you want to "disable" guests. + +authOAuth2RedirectURL: "http://localhost:1337/oauth/callback" +authOAuth2Providers: + authentik: + name: authentik + title: Authentik + clientID: "1234567890" + clientSecret: "123456789012345" + authURL: "http://localhost:9000/application/o/authorize/" + tokenURL: "http://localhost:9000/application/o/token/" + whoamiURL: "http://localhost:9000/application/o/userinfo/" + usernameField: "preferred_username" + icon: +---- + +Optional configuration values to consider are: +[source,yaml] +---- +authHttpHeaderUserGroupSep: "," # Optional - only needed if you use the multiple group mapping + +authOAuth2Providers: + authentik: + userGroupField: "olivetin_group_list" # or "olivetin_group_first" or "olivetin_group_specific" depending on which mapping you used + certBundlePath: "/path/to/mounted/certificate.pem" + insecureSkipVerify: true + connectTimeout: 15 +---- + +You will need to restart OliveTin for the changes to take effect. + +== Testing + +You should now be able to login to OliveTin using Authentik, on the OliveTin page, a "Login" link should be available in the top right corner. This will take you to the login form, where you can select the Authentik provider. + +image::authentik_login.png[] + +When clicking on the Authentik login button, you will be redirected to the Authentik login page which should look something like this; + +image::authentik_login2.png[] + +Assuming that you have given permission to OliveTin to access your account, you should be redirected back to OliveTin and logged in. You can verify that you are logged in by checking the top right corner of the OliveTin page, where your username should be displayed. + +image::authentik_login3.png[] + +== Debugging + +OliveTin logs OAuth2 flows quite extensively. If you are having trouble with OAuth2, you should check your OliveTin logs. + +You may see errors such as "OAuth2: Error getting user data" or "Failed to get field from user data". + +Sometimes it can be infuriating to debug the user data mapping (username and usergroup), as you cannot easily capture the data that is being sent back from Authentik. To help with this, you can temporarily enable a debug log flag that is INSECURE (do not leave this enabled) to log the user data that is being sent back from Authentik. To do this, add the following to your OliveTin configuration file: + +[source,yaml] +---- +logLevel: debug +insecureAllowDumpOAuth2UserData: true +---- + +Once you have this working, you can disable the `insecureAllowDumpOAuth2UserData` flag again. This is only meant for debugging purposes and should not be left enabled in production environments. + +== Next steps + +Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page. + diff --git a/docs/modules/ROOT/pages/security/oauth2_pocketid.adoc b/docs/modules/ROOT/pages/security/oauth2_pocketid.adoc new file mode 100644 index 0000000..26418d1 --- /dev/null +++ b/docs/modules/ROOT/pages/security/oauth2_pocketid.adoc @@ -0,0 +1,55 @@ +[#oauth2-pocketid] += OAuth2 - Pocket ID + +OliveTin has been tested with Pocket ID. This documentation page describes how to configure Pocket ID for use with OliveTin and assumes you already have Pocket ID installed and running. + +== Configuration + +.config.yaml +[source,yaml] +---- +authRequireGuestsToLogin: true + +accessControlLists: + - name: admin + permissions: + view: true + exec: true + logs: true + matchUsergroups: + # Since you can't map properties in userinfo response from Pocket ID I am kind of cheating here: + # only I will be able to log in and so I return the "preferred_username" as the group, and I configure my + # own username as the "Usergroup" to mean "admin" + - myusername + addToEveryAction: true + +authLocalUsers: + enabled: false + +authOAuth2RedirectUrl: https://olivetin.example.com/oauth/callback + +authOAuth2Providers: + pocket-id: + name: pocket-id + title: Pocket ID + icon: '' + authUrl: https://id.example.com/authorize + tokenUrl: https://id.example.com/api/oidc/token + whoamiUrl: https://id.example.com/api/oidc/userinfo + clientId: "[REDACTED]" + clientSecret: "[REDACTED]" + scopes: + - profile + - email + usernameField: preferred_username + userGroupField: preferred_username + insecureSkipVerify: true + +actions: + - title: "Hello world!" + shell: echo 'Hello World!' +---- + +== Pocket ID config + +image::pocketid.png[] \ No newline at end of file diff --git a/docs/modules/ROOT/pages/security/trusted_header.adoc b/docs/modules/ROOT/pages/security/trusted_header.adoc new file mode 100644 index 0000000..a395238 --- /dev/null +++ b/docs/modules/ROOT/pages/security/trusted_header.adoc @@ -0,0 +1,34 @@ +[#trusted-header] += Trusted Header Authorization + +Trusted Header Authorization is useful if you have a proxy that handles authentication, and you just want OliveTin to trust HTTP Servers sent via that proxy. + +This comes with the obvious security caveat that anyone who can set HTTP headers on requests to OliveTin can impersonate any user or usergroup. Therefore, you should only use this method if you are sure that requests to OliveTin are **only coming from trusted proxies**. + +== Configuring your reverse proxy + +You will need to configure your reverse proxy to set a header for the Username (eg `X-Username`) and optionally a header for Usergroup (eg `X-Usergroup`). How you do this will depend on your reverse proxy software. It is better that you check out the documentation for your reverse proxy software for how to set HTTP headers. + +== Configuring OliveTin + +To configure Trusted Header Authorization, set the following configuration options in your `config.yaml` file: + +[source,yaml] +.`config.yaml` +---- +authHttpHeaderUsername: "X-Username" +authHttpHeaderUsergroup: "X-Usergroup" +---- + +The value of `X-Username` and `X-Usergroup` can be whatever you like, as long as they match the headers set by your reverse proxy. + +NOTE: You *must* set `AuthHttpHeaderUsername` to some value, even if you only intend to use `AuthHttpHeaderUsergroup`, otherwise usergroups will be ignored. + +== Multiple usergroups + +OliveTin will automatically detect multiple usergroups in the `authHttpHeaderUsergroup` header if they are separated by a space. You can also set a configuration option to use a different separator string with `authHttpHeaderUsergroupSep`. For example, if you set `authHttpHeaderUsergroupSep` to `,`, then the header `X-Usergroup: group1,group2` will be interpreted as two usergroups: `group1` and `group2`. + +[source, yaml] +---- +authHttpHeaderUsergroupSep: "," +---- diff --git a/docs/modules/ROOT/pages/solutions/cloudflare_access_tunnel/index.adoc b/docs/modules/ROOT/pages/solutions/cloudflare_access_tunnel/index.adoc new file mode 100644 index 0000000..8499835 --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/cloudflare_access_tunnel/index.adoc @@ -0,0 +1,66 @@ +[#cloudflare-access-tunnels] += Cloudflare Access & Tunnels + +include::partial$earlydoc.adoc[] + +Several uses use Cloudflare Access & Tunnels to grant access to OliveTin. There is no special configuration needed for OliveTin to work in this way, simply setup your Cloudflare tunnel to connect to OliveTin on port 1337. + +== Trusting the Cloudflare JWT Token + +. Get your **AUD** Tag (`authJwtAud`) +.. Login to your CloudFlare dashboard and go to link:https://one.dash.cloudflare.com/[**Zero Trust**] +.. Go to **Access > Applications.** +.. Select **Configure** for your application. +.. On the Overview tab, copy the **Application Audience (AUD) Tag**. +. Get your **Team Domain** (`authJwtDomain`) +.. Login to your CloudFlare dashboard and go to link:https://one.dash.cloudflare.com/[**Zero Trust**] +.. Go to **Settings** +.. Go to **Custom Pages** +.. Your **Team Domain** is shown here +. Get your Certs URL (`authJwtCertsURL`) +.. Simply add `cdn-cgi/access/certs` to your **Team Domain** for CloudFlare +. CloudFlare gives you an `email` in the claim (`authJwtClaimUsername`) and the Cookie is always called `CF_Authorization` (`authJwtCookieName`) +. Setup your OliveTin config.yaml like follows; + +[source,yaml] +.`config.yaml` +---- +authJwtAud: "asdf1234" +authJwtDomain: "https://mydomain.cloudflareaccess.com" +authJwtCertsURL: "https://mydomain.cloudflareaccess.com/cdn-cgi/access/certs" +authJwtClaimUsername: email +authJwtCookieName: "CF_Authorization" +---- + +You may well want to set `logLevel: DEBUG` and `insecureAllowDumpJwtClaims: true` in your config when testing JWT for the first time. + +== Trusting the authentication header (not recommended) + +If you are using Cloudflare Access, and want to use the username given by Cloudflare in OliveTin ACLs, then you can use the Cloudflare cookie like this; + +[source,yaml] +.`config.yaml` +---- +authHttpHeaderUsername: "Cf-Access-Authenticated-User-Email" + +defaultPermissions: + view: false + exec: false + +accessControlLists: + - name: Admins + addToEveryAction: true + matchUsernames: + - contact@jread.com + permissions: + view: true + exec: true + +actions: + - title: test apprise + shell: date + shellAfterCompleted: "apprise -c /config/apprise.yml -t 'notification: test' -b 'date is {{ stdout }}'" +---- + +NOTE: OliveTin does support JWT cookies that Cloudflare uses, which is arguably more secure. It's just that nobody in the Discord has worked out how to get the keys needed from Cloudflare to decrypt this cookie yet! See the xref::security/jwt.adoc[JWT] documentation for some starter points. If you figure this out, it would be most welcome to share your solution with the community. + diff --git a/docs/modules/ROOT/pages/solutions/container-control-panel/index.adoc b/docs/modules/ROOT/pages/solutions/container-control-panel/index.adoc new file mode 100644 index 0000000..1f0140d --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/container-control-panel/index.adoc @@ -0,0 +1,32 @@ +[#container-control-panel] += Container Control Panel + +OliveTin is frequently used to create simple container control panels, this is one of the default examples that ships with the standard OliveTin config.yaml. + +image::solutions/container-control-panel/preview.png[] + +include::partial$container_socket.adoc[] + +== Entity file + +To build this Container Control dashboard, we use an xref::entities/intro.adoc[entity file] that stores and updates produced by `docker ps --format json > /etc/OliveTin/entities/containers.json` + +[source,yaml] +.`/etc/OliveTin/entities/containers.json` +---- +include::example$solutions/container-control-panel/config/containers.json[] +---- + +You can generate this file yourself the first time, but the `config.yaml` below shows how OliveTin can run the `docker ps` command on startup, and on a schedule to update the file. + +== Configuration + +Then use the following configuration file; + +[source,yaml] +.`config.yaml` +---- +include::example$solutions/container-control-panel/config/config.yaml[] +---- + + diff --git a/docs/modules/ROOT/pages/solutions/directory-actions/index.adoc b/docs/modules/ROOT/pages/solutions/directory-actions/index.adoc new file mode 100644 index 0000000..2568c73 --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/directory-actions/index.adoc @@ -0,0 +1,17 @@ +[#directory-actions] += Directory Actions + +Sometimes people want to use OliveTin to run standard commands on a directory, such a cleaning out a directory of logs. + +image::directory-actions-screenshot.png[] + +== Config file + +This is a quick and simple way to build actions based on directories. + +[source,yaml] +.`/etc/OliveTin/config.yaml` +---- +include::example$solutions/directory-actions/config.yaml[] +---- + diff --git a/docs/modules/ROOT/pages/solutions/heating-control-panel/index.adoc b/docs/modules/ROOT/pages/solutions/heating-control-panel/index.adoc new file mode 100644 index 0000000..fcd116c --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/heating-control-panel/index.adoc @@ -0,0 +1,28 @@ +[#heating-control-panel] += Heating Control Panel + +This was inspired by a GitHub issue to control heating; https://github.com/OliveTin/OliveTin/issues/73 + +image::dashboard-heating-control-panel.png[] + +== Entity file + +To build this, we use an xref::entities/intro.adoc[entity file] that stores and updates the status of a heater outside of OliveTin. + +[source,yaml] +.`/etc/OliveTin/entities/heating.yaml` +---- +include::example$solutions/heating-control-panel/configs/heating.yaml[] +---- + +== Configuration + +Then use the following configuration file; + +[source,yaml] +.`config.yaml` +---- +include::example$solutions/heating-control-panel/configs/config.yaml[] +---- + + diff --git a/docs/modules/ROOT/pages/solutions/human-in-the-control-loop/index.adoc b/docs/modules/ROOT/pages/solutions/human-in-the-control-loop/index.adoc new file mode 100644 index 0000000..2431c48 --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/human-in-the-control-loop/index.adoc @@ -0,0 +1,37 @@ +[#human-in-the-control-loop] += Human in the Control Loop + +This solution shows a simple control-panel pattern where a human operator sees live status on a dashboard and decides when to run an action. + +A hidden background action keeps the displayed status up to date, while the dashboard only shows the operator control you want them to use. + +image::solutions/human-in-the-control-loop/preview.png[] + +== How it works + +* **Pump ON - 5m** is the only visible action on the dashboard. The operator starts the pump when they choose. +* **Update Water Level** is a xref:action_execution/triggers.adoc[hidden] action that runs on startup and on a schedule. It prints dummy output (`Water level 47%`) that OliveTin keeps in the action logs. +* A xref:dashboards/5-output-views.adoc[most recent execution] dashboard component shows the latest output from **Update Water Level**, so the operator always sees the current reading without clicking a refresh button. + +When the pump action finishes, it xref:action_execution/triggers.adoc[triggers] **Update Water Level** so the displayed value can be refreshed after manual control. + +== Configuration + +[source,yaml] +.`config.yaml` +---- +include::example$solutions/human-in-the-control-loop/config.yaml[] +---- + +== Customising this pattern + +* Replace the dummy `echo` commands with scripts that read real sensors or call your APIs. +* Change the cron schedule on **Update Water Level** to match how often the status should refresh. +* Add ACLs if only certain users should see the dashboard or run the pump action. + +== See also + +* xref:dashboards/5-output-views.adoc[Most recent action output] +* xref:action_execution/oncron.adoc[Execute on schedule (cron)] +* xref:action_execution/onstartup.adoc[Execute on startup] +* xref:action_execution/triggers.adoc[Triggers] diff --git a/docs/modules/ROOT/pages/solutions/intro.adoc b/docs/modules/ROOT/pages/solutions/intro.adoc new file mode 100644 index 0000000..53741ae --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/intro.adoc @@ -0,0 +1,7 @@ +[#solutions] += Solutions + +OliveTin was designed to be very simple, but sometimes OliveTin can get complex - especially if you are trying to do something where you need to jump between lots of different parts of the documentation! + +This section of the docs is designed to bring everything into one place and present a scenario, with a single-page solution. + diff --git a/docs/modules/ROOT/pages/solutions/k8s-control-panel-hosted/index.adoc b/docs/modules/ROOT/pages/solutions/k8s-control-panel-hosted/index.adoc new file mode 100644 index 0000000..310005e --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/k8s-control-panel-hosted/index.adoc @@ -0,0 +1,116 @@ += Solution: Kubernetes Control Panel (Hosted) + +This solution gives you quick and easy buttons to run kubectl commands, when OliveTin is running on top of Kubernetes (this means OliveTin is "hosted" by Kubernetes). This can be very easy for quick debugging purposes when you cannot type (eg from a mobile phone), or to give junior sysadmins access to do basic predefined tasks. + +This use case is enabled by simply providing the OliveTin pod access to talk to the kubernetes API, and using `kubectl` which is preinstalled with modern versions of OliveTin. + +image::solutions/k8s-control-panel-hosted/preview.png[] + +== Requirements & Assumptions + +=== Time & skills + +* This should take approximately **10 minutes** to configure if you are comfortable in using Kubernetes - using helm, kubectl, and editing basic YAML. + +=== Environment + +* A Kubernetes cluster that is up and running. +* Kubernetes permissions to create a helm deployment, a `ClusterRole` and `ClusterRoleBinding`. +* A configured Ingress Controller, exposing the for web interface + +=== System + +* Approximately 128m RAM, 1vCPU to run the OliveTin pod. + +== Install OliveTin on top of Kubernetes + +* xref:install/helm.adoc[Install OliveTin on Kubernetes with Helm] (recommended) +* xref:install/k8s.adoc[Install OliveTin on Kubernetes with Manifests] + +== Grant permissions to API + +OliveTin needs a `ClusterRole` that allow it to access resources on your Kubernetes cluster. This is because by default, pods can communicate to the API using the credentials mounted in the pod by the default `ServiceAccount`, but they don't have any permissions. This `ClusterRole` is being created to give permissions to the `ServiceAccount`. Create the `ClusterRole` like follows; + +[tabs] +==== +kubectl cli:: ++ +-- +[source,shell] +---- +user@host: kubectl create clusterrole --resource=pods --verb=get,list,watch olivetin-k8s-permissions +---- +-- +manifest:: ++ +-- +[source,yaml] +---- +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: olivetin-k8s-permissions +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +---- +-- +==== + +Now that the `ClusterRole` has been created, we need to associate it to a `ServiceAccount` with a `ClusterRoleBinding`. +Create a cluster role binding; + +[tabs] +==== +kubectl cli:: ++ +-- +[source,shell] +---- +user@host: kubectl create clusterrolebinding --clusterrole=olivetin-k8s-permissions --serviceaccount myolivetinnamespace:default --namespace myolivetinnamespace olivetin-crb +---- +-- +==== + +== Build a simple kubernetes control planel + +Add `kubectl` job to OliveTin config with `kubectl edit cm/olivetin-config -n olivetin`; + +[source,yaml] +---- +apiVersion: v1 +data: + config.yaml: | + defaultOnClick: execution-dialog-output-only + + actions: + - title: get pods + icon: + shell: kubectl get pods + + - title: restart postgres deployment + icon: + shell: kubectl rollout restart deployment postgres + + - title: evacuate node + icon: + shell: kubectl drain {{ NodeName }} --ignore-daemonsets --delete-emptydir-data + arguments: + - name: NodeName + choices: + - value: node1 + - value: node2 + - value: node3 +kind: ConfigMap +metadata: + annotations: + meta.helm.sh/release-name: olivetin + meta.helm.sh/release-namespace: default + labels: + app.kubernetes.io/managed-by: Helm + name: olivetin-config + namespace: default +---- + +Don't forget to restart the OliveTin deployment as good measure, because Kubernetes can be slow to update configmaps. diff --git a/docs/modules/ROOT/pages/solutions/on-git-push/index.adoc b/docs/modules/ROOT/pages/solutions/on-git-push/index.adoc new file mode 100644 index 0000000..361865b --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/on-git-push/index.adoc @@ -0,0 +1,115 @@ +[#solution-on-git-push] += GitOps (run actions on Git Push) + +image::gitops.png[] + +A really helpful thing to do with OliveTin is to have it run actions when you push to a Git repository. This is a great way to automate things like running tests, building your project, or deploying your code - this turns OliveTin into a powerful GitOps tool, or even a Continuous Integration tool. + +This guide assumes that you are using a self-hosted Git repository, and uses a standard Git `post-receive` hook to trigger OliveTin actions. + +[TIP] +==== +**Using GitHub?** OliveTin has built-in support for GitHub webhooks with templates that make configuration simple. See xref:action_execution/onwebhook_github.adoc[GitHub Webhooks] for an easier approach that doesn't require writing hook scripts. +==== + +To set up OliveTin to run actions on Git push, you will need to: + +1. Create a new OliveTin action that you want to run on push. +2. Set up a Git `post-receive` hook to trigger the OliveTin action. + +== Create a New OliveTin Action + +First, you will need to create a new OliveTin action that you want to run when you push to your Git repository. This could be anything you like - for example, running tests, building your project, or deploying your code. The example below is a simple action that echoes a message to the console: + +[source,yaml] +.OliveTin `config.yaml` +---- +actions: + - title: Run on Git Push + id: gitops + icon: + shell: | + echo "You just pushed commit $COMMIT to git, running action..." + date + arguments: + - name: commit + type: ascii +---- + +Note that OliveTin will expose all arguments as environment variables in uppercase as shown in the example above. You can of course use the `{{ commit }}` syntax instead and it will do the same thing. + +== Add a Git hook script + +The following below assumes that you have a Git repository initialized as a bare repository, at `/opt/myrepo.git`. If you have a different repository location, you will need to adjust the paths accordingly. + +First, create a new file at `/opt/myrepo.git/hooks/post-receive` with the following contents: + +[source,bash] +.Script: `myrepo.git/hooks/post-receive` +---- +#!/bin/bash + +read OLDREV NEWREV REFNAME + +CHANGED_FILES=$(git diff --name-only $OLDREV $NEWREV) + +commit_contains_path() { + local filename=$1 + + if echo "$CHANGED_FILES" | grep -q "$filename"; then + return 0 # True + else + return 1 # False + fi +} + +function run_olivetin_action() { + local ACTION_NAME=$1 + + echo "Requesting OliveTin job $ACTION_NAME" + + OLIVETIN_REQUEST="$(cat <`. Just before that code statement, add this code, save and close the file. ++ +[source,html] +---- + +---- +You will need to this every time you upgrade OliveTin. ++ +. Create the `password.js` using the code below, at `/etc/OliveTin/custom-webui/password.js`. + +[source,javascript] +.`password.js` +---- +include::example$solutions/primitive-password/password.js[] +---- diff --git a/docs/modules/ROOT/pages/solutions/systemd-control-panel/index.adoc b/docs/modules/ROOT/pages/solutions/systemd-control-panel/index.adoc new file mode 100644 index 0000000..5488391 --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/systemd-control-panel/index.adoc @@ -0,0 +1,42 @@ +[#systemd-control-panel] += Systemd Control Panel + +OliveTin can be used to manage selected systemd units (services) as well. This is powered by OliveTin's powerful xref::entities/intro.adoc[entity support]. Here is a screenshot of what that can look like (dark theme preference enabled). + +image::solutions/systemd-control-panel/preview.png[] + +NOTE: To control systemd, you will need the root user. If you are running OliveTin systemd service itself, then OliveTin should be configured to run as root. The alternative is to use OliveTin in a container, and use xref:action_examples/ssh-easy.adoc[SSH] to connect back to the host (as a user that can manage systemd -usually root). This is not an OliveTin limitation, it's just how systemd security works. + +== Entity file + +To build this Systemd Control dashboard, we use an xref:entities/intro.adoc[entity file] that stores and updates produced by `systemctl list-units..`, output to a json format, and then filtered with jq (JSON Query). Here is the full command that we will use in our config later; + +---- +user@host: systemctl list-units -a -o json --no-pager | jq -c 'map(select (.unit | contains ("upsilon", "podman", "boot.mount"))) | .[]' > /etc/OliveTin/entities/systemd_units.json +---- + +That command will generate a file (example shown below). Let's break down that long command a little bit to explain what it is doing; + +. `systemctl list-units -a -o json --no-pager` - will list units, regardless of status - started, stopped, etc (`-a`), in JSON format, and output the results +. `jq` (JSON Query) will select units from that output that match "upsilon", "podman", "boot.mount", and of course you can change this expression to add your own services. +. The `map()` and `.[]` parts of the expression basically just put those units line by line into the file + +[source,json] +.An example generated `/etc/OliveTin/entities/systemd_units.json` file +---- +include::example$solutions/systemd-control-panel/config/systemd_units.json[] +---- + +You can generate this file yourself the first time, but the `config.yaml` below shows how OliveTin can run the `systemctl list-units ...` command on startup, and on a schedule to update the file. + +Note that if the file does not exist the first time OliveTin starts up, then OliveTin will will issue an error about not finding the file to monitor it. An easy way around this is to simply restart OliveTin a 2nd time, so that on the 2nd startup it will find the file (because it will be created the first time OliveTin starts up). + +== Configuration + +Finally, here is the example configuration file to build the dashboard; + +[source,yaml] +.`config.yaml` +---- +include::example$solutions/systemd-control-panel/config/config.yaml[] +---- diff --git a/docs/modules/ROOT/pages/solutions/wol/index.adoc b/docs/modules/ROOT/pages/solutions/wol/index.adoc new file mode 100644 index 0000000..49582bb --- /dev/null +++ b/docs/modules/ROOT/pages/solutions/wol/index.adoc @@ -0,0 +1,54 @@ +[#wol] += Wake On LAN from a container + +This is a simple solution that provides wake on lan capabilities from inside a container. It uses the simple `ether-wake` command to send the magic packet. This can be incredibly helpful if you just need a simple button to click to wake up a machine on your network. + +Docker containers will normally use a docker network, which is a separate network from the host network. This means that the container will not be able to send the magic packet to the host network. Therefore, we need to create the container with the `--network host` option, which will allow the container to send the magic packet to the host network (not the docker network). + + +The container is also created with the `--user root` option, which allows the container to send the magic packet as root. This is necessary because the `ether-wake` command requires root privileges to send the magic packet, and also to install the `ether-wake` command, which is bundled with `net-tools` in the container. + +Create the container as follows; + +```bash +user@host: docker create -u root --network=host --name olivetin_wol -v /etc/OliveTin/:/config ghcr.io/olivetin/olivetin:latest +``` + +Create your OliveTin `config.yaml` file in the `/etc/OliveTin/` directory on the host, with the following content; + +[source,yaml] +---- +include::example$solutions/wol/config.yaml[] +---- + +Obviously adjust the config file with your own MAC addressses, creating new actions to send WOL commands as needed. + +Then start the container with the following command; + +``` +docker start olivetin_wol +``` + +Then visit your OliveTin web interface at http://yourServer:1337 and you should see something that looks like this; + +image::solutions/wol/preview.png[] + +== Wake on LAN via docker container + +This is an alternative solution that provides WoL capabilities to the OliveTin container, + +but has the advantage the OliveTin container does not have to run on the host network ( `--network host` ). +This may be useful if your networking configuration relies on docker `bridge` +networks (or other more complex networking configurations). + +It requires that OliveTin is configured with permissions that allow it to control docker. + +include::partial$container_socket.adoc[] + +Now you can add actions to your OliveTin config file that use a separate +docker container (on the `host` network) to send the WOL commands. + +[source,yaml] +---- +include::example$solutions/wol/config_docker.yaml[] +---- diff --git a/docs/modules/ROOT/pages/style.css b/docs/modules/ROOT/pages/style.css new file mode 100644 index 0000000..4b0bf11 --- /dev/null +++ b/docs/modules/ROOT/pages/style.css @@ -0,0 +1,212 @@ +body { + font-family: sans-serif; + line-height: 1.5; + padding: 0; + margin: 0; + display: grid; + grid-template-areas: "nav content" "nav footer"; + grid-template-columns: 20em auto; + grid-template-rows: auto min-content; + height: 100vh; + align-items: stretch; +} + +a { + text-decoration: none; + color: #36f; + font-weight: bold; +} + +a:focus, #toc a:focus { + background-color: black; + color: white; +} + +a:visited { + color: #36f; + font-weight: bold; +} + +a:hover { + text-decoration: underline; +} + +#header { + background-color: #fafafa; + grid-area: nav; + box-shadow: 0 0 10px #cecece; +} + +#footer { + grid-area: footer; + justify-self: center; + align-self: end; + padding: 1em; +} + +#toc { + padding: 1em; +} + +#toc a { + color: black; + font-weight: normal; +} + +#toc li { + padding-bottom: .6em; +} + +#toc ul { + list-style: none; + margin: 0; + padding: 0; +} + +#toc ul ul { + padding-left: 1.4em; +} + +#toctitle { + font-weight: bold; + color: black; +} + +#content ul { + list-style: '\25b9 '; +} + +h1 { + font-size: 1.4em; + font-weight: lighter; + background-color: black; + padding: .6em; + margin: 0; + color: white; +} + +#toc a:hover { + text-decoration: underline; +} + +#content { + background-color: white; + grid-area: content; + max-width: 1000px; + padding: 1em; + justify-self: center; +} + +#content img { + max-width: 100%; + vertical-align: middle; +} + +code { + background-color: #f7f7f8; +} + +pre { + background-color: #f7f7f8; + padding: 1em; + word-wrap: break-word; + white-space: no-wrap; + overflow: auto; +} + +table { + border-collapse: collapse; +} + +td, th { + border: 1px solid gray; + padding: .4em; +} + +th { + background-color: #f9f9f9; + text-align: left; +} + +.imageblock { + float: center; +} + +.imageblock.right { + float: right; +} + +.imageblock .title { + margin-bottom: 2em; +} + +.imageblock img { + border-radius: 1em; + box-shadow: 0 0 10px #cecece; +} + +.imageblock.right img { + box-shadow: none; +} + + +.listingblock { + box-shadow: 0 0 10px #cecece; + border-radius: 1em; +} + +.listingblock .title { + border-radius: 1em 1em 0 0; +} + +.listingblock .title ~ .content, .listingblock .title ~ .content pre { + border-radius: 0 0 1em 1em; +} + +.listingblock .content, .listingblock .content pre { + border-radius: 1em; +} + +hr { + clear: both; + border: 0; + border-bottom: 1px solid #efefef; + margin: 1em; +} + +div.admonitionblock.note { + background-color: beige; +} + +div.admonitionblock td { + border: 0; +} + +div.admonitionblock td:first-child { + font-weight: bold; +} + +.listingblock .title { + background-color: #efefef; + margin-bottom: -1em; + padding: 1em; +} + +.red { + color: white; + border: 1px solid black; + padding: .2em; + font-weight: bold; + background-color: #cc0000; +} + +.red a { + color: white; +} + +@media (max-width: 700px) { + body { + display: block; + } +} + diff --git a/docs/modules/ROOT/pages/troubleshooting/advanced.adoc b/docs/modules/ROOT/pages/troubleshooting/advanced.adoc new file mode 100644 index 0000000..149e5b7 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/advanced.adoc @@ -0,0 +1,25 @@ +[#advanced-troubleshooting] += Advanced Troubleshooting + +Sometimes you need to really see what OliveTin is doing, especially when debugging entities. OliveTin has several built-in options for advanced troubleshooting, but enabling these output options can expose sensitive information, so they can be insecure. + +NOTE: OliveTin itself is not "insecure" by using these options, they would not let attackers execute different commands or anything like that. It's just that using these options can expose data (like entity files) that maybe you don't want an attacker to see. + +All these configuration options are `false` by default, and should be deleted from the config or reset back to `false` when you are not using them. + +[#dump-server-diagnostics] +== Dump server diagnostics +`InsecureAllowDumpSos: true` - will allow dumping xref:troubleshooting/server-diagnostics.adoc[server diagnostics] as plain text when visiting `http://server:1337/api/sosreport` + +[#dump-action-map] +== Dump Action Map +`InsecureAllowDumpActionMap: true` - will allow dumping all the actions (and those generated with entities) and their public IDs, eg: `http://server:1337/api/DumpActionMap` + +[#dump-vars] +== Dump Vars +`InsecureAllowDumpVars: true` - will allow dumping all the "string variables" from a map that is mainly used for entities, eg: `http://server:1337/api/DumpVars` + +[#dump-jwt] +== Dump JWT Claims + +`InsecureAllowDumpJwtClaims: true` - will dump all the claims from a successfully parsed JWT token. This can be useful when trying to see how OliveTin is parsing the token, and what key fields are available. diff --git a/docs/modules/ROOT/pages/troubleshooting/browser-console-logs.adoc b/docs/modules/ROOT/pages/troubleshooting/browser-console-logs.adoc new file mode 100644 index 0000000..a239720 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/browser-console-logs.adoc @@ -0,0 +1,55 @@ +[#browser-console-logs] += Browser console logs (WebUI troubleshooting) +:experimental: + +The **developer console** (or **browser console**) is a panel built into your web browser. It records technical messages from the OliveTin WebUI—errors, warnings, and network failures—that do not always appear on the page itself. + +When something looks wrong in the WebUI (for example a blank area, buttons that never load, or errors after clicking), sharing **console output** helps others see what the browser reported and narrow down the cause. You do **not** need to be a web developer to open it or share it. + +**A screenshot of the console is often enough.** If you can copy text instead, that is helpful too. Either way, include what you were doing right before the problem (for example “opened the dashboard”, “clicked Start on action X”). + +TIP: Before posting publicly, glance at the console for anything that looks like a password, token, or private URL. Crop or redact those lines if needed. + +[#open-the-console] +== Open the console + +Use OliveTin in the browser where you see the problem, then open the console *before* or *right after* reproducing the issue so the messages are still visible. + +[#desktop-chromium] +=== Google Chrome, Microsoft Edge, Brave, and other Chromium-based browsers + +. Open the OliveTin page. +. Open the developer tools: +** *Windows / Linux:* press kbd:[F12], or kbd:[Ctrl+Shift+J] to go straight to the *Console*. +** *macOS:* press kbd:[Cmd+Option+J] for the *Console*, or kbd:[Cmd+Option+I] for developer tools (then click the *Console* tab). +. If you do not see a *Console* tab, click *»* or *+* in the developer tools toolbar and choose *Console*. + +[#desktop-firefox] +=== Mozilla Firefox + +. Open the OliveTin page. +. Open developer tools: kbd:[F12], or kbd:[Ctrl+Shift+I] on Windows/Linux, or kbd:[Cmd+Option+I] on macOS. +. Select the *Console* tab. (On Windows/Linux you can also use kbd:[Ctrl+Shift+K] to open the console directly.) + +[#desktop-safari] +=== Safari (macOS) + +Safari hides the console until you turn on the Develop menu: + +. *Safari* → *Settings* (or *Preferences*) → *Advanced* → enable *Show features for web developers* (wording may vary slightly by Safari version). +. In the menu bar, open *Develop* → *Show JavaScript Console*, or press kbd:[Cmd+Option+C]. + +[#what-to-capture] +== What to capture + +. Stay on the *Console* tab. +. If there are many old messages, use the console’s *Clear* control so only new messages appear, then reload the page or repeat the steps that trigger the problem. +. Note any lines in **red** (errors) or **yellow** (warnings)—those are usually the most useful. +. Use your system's screenshot tool to capture the console window, *or* right-click in the console → *Save as…* / *Copy all messages* if your browser offers it. + +[#share-the-logs] +== Share the logs + +Attach the screenshot or pasted text to a xref:troubleshooting/wheretofindhelp.adoc[Discord or GitHub support] message, along with your OliveTin version and how you access it (direct URL, reverse proxy, etc.). That combination helps diagnose WebUI issues much faster than a description of the screen alone. + +If the problem might be on the server (OliveTin not starting, actions failing, API errors), also collect xref:troubleshooting/service-logs.adoc[service logs] from Docker, Podman, or systemd. diff --git a/docs/modules/ROOT/pages/troubleshooting/err-fetch-buttons.adoc b/docs/modules/ROOT/pages/troubleshooting/err-fetch-buttons.adoc new file mode 100644 index 0000000..41ecb1a --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/err-fetch-buttons.adoc @@ -0,0 +1,6 @@ +[#err-fetch-buttons] += Error Getting Buttons + +This is most often caused by not being able to get to /api/ properly - normally due to a bad proxy config, or your network was disconnected. + + diff --git a/docs/modules/ROOT/pages/troubleshooting/err-fetch-webui-settings.adoc b/docs/modules/ROOT/pages/troubleshooting/err-fetch-webui-settings.adoc new file mode 100644 index 0000000..6f8eaa8 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/err-fetch-webui-settings.adoc @@ -0,0 +1,9 @@ +[#err-fetch-webui-settings] += Error Fetching WebUI Settings + +This is a less common issue, but it means that the main web HTML has loaded, but it could not get http://yourserver:1337/webUiSettings.json - most likely because of a 404 (Not Found) or JSON parse issue. You can see the exact reason if your browser as Web Developer Tools - look in the console. Firefox and Chrome both have great web developer tools, that can normally be opened with the F12 key, or from the developer tools menu. + +The most common cause of this issue is a broken reverse proxy configuration. To debug this, browse to http://yourserver:1337/webUiSettings.json and adjust your reverse proxy configuration until the file loads properly. See xref:reverse-proxies/intro.adoc[Reverse Proxies] for common configuration instructions. + +An uncommon cause of this issue is if you are self-hosting the HTML outside of OliveTin. This is supported, but it means you will need to write the webUiSettings.json file manually. This is currently not documented as very very few people really need or want to do this. It's very uncommon at the moment to self host the HTML outside of the OliveTin main server. Jump on the discord to discuss this if you want to do this, see xref:troubleshooting/wheretofindhelp.adoc[support]. + diff --git a/docs/modules/ROOT/pages/troubleshooting/err-js-modules-not-supported.adoc b/docs/modules/ROOT/pages/troubleshooting/err-js-modules-not-supported.adoc new file mode 100644 index 0000000..97b438d --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/err-js-modules-not-supported.adoc @@ -0,0 +1,10 @@ +[#err-js-modules-not-supported] += Error: JS Modules not supported + +This is most likely because you are using a very old browser, or have some Javascript disabled. + +This page describes the compatible browsers for Javascript modules; https://caniuse.com/es6-module . + +There is no workaround for this apart from updating your browser / using a better browser, as OliveTin wants to be a progressive web app and doesn't intend to support browsers without module support. + + diff --git a/docs/modules/ROOT/pages/troubleshooting/err-websocket-connection.adoc b/docs/modules/ROOT/pages/troubleshooting/err-websocket-connection.adoc new file mode 100644 index 0000000..d9c7624 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/err-websocket-connection.adoc @@ -0,0 +1,14 @@ +[#err-websocket-connection] += Error Connecting to WebSocket + +If OliveTin was working, but this error popped up, it's most likely because your reverse proxy closed the connection due to a timeout. + +If OliveTin is always displaying this error, it's probably your reverse proxy not handling the websocket properly. Please check the Reverse Proxy documentation for more information. + +== Reverse proxies will close websockets + +Many reverse proxies use short default timeouts for connections. Long-lived websocket connections often hit these limits and get closed by the proxy, which triggers this error. OliveTin will attempt to reconnect automatically, but you can avoid the disconnects by increasing the websocket (or proxy) timeout in your reverse proxy configuration. + +Check your proxy's documentation for how to raise timeouts—for example, Apache HTTPD uses `ProxyTimeout`, and other proxies have similar settings. See the individual reverse proxy pages (e.g. xref:reverse-proxies/apache.adoc[Apache]) for examples. + + diff --git a/docs/modules/ROOT/pages/troubleshooting/err-webui-mismatch.adoc b/docs/modules/ROOT/pages/troubleshooting/err-webui-mismatch.adoc new file mode 100644 index 0000000..4667855 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/err-webui-mismatch.adoc @@ -0,0 +1,12 @@ += Error: WebUI Version Mismatch + +This is most often caused by the WebUI Version not matching the server version. + +Every release of OliveTin will change the version number for the server, and for the webui client - these versions should match. For example, version 2024.4.1 of the server should be paired with version 2024.4.1 of the webui client. It is unlikely that the release of the server and webui client will be out of sync, but it is possible if you are using a custom build of the webui client, but the most likely cause is **aggressive caching** of the webui client files in your browser, or proxy server. + +There are cache-busting mechanisms built into OliveTin to try and avoid this error, but they don't always work in every environment. + +* Try forcing a refresh with "Ctrl+F5" in your browser. +* If that doesn't work, try clearing your browser cache. +* If that doesn't work, try using a different browser. + diff --git a/docs/modules/ROOT/pages/troubleshooting/exit127.adoc b/docs/modules/ROOT/pages/troubleshooting/exit127.adoc new file mode 100644 index 0000000..f442904 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/exit127.adoc @@ -0,0 +1,5 @@ += Exit code 127 + +Exit code 127 on Linux typically means "command not found". This can be the +case when you need to install command in a container image for example. + diff --git a/docs/modules/ROOT/pages/troubleshooting/log-debug-options.adoc b/docs/modules/ROOT/pages/troubleshooting/log-debug-options.adoc new file mode 100644 index 0000000..cf18c21 --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/log-debug-options.adoc @@ -0,0 +1,12 @@ +[#log-debug-options] += Debug Log Options + +[source,yaml] +---- +logDebugOptions: + singleFrontendRequests: true + singleFrontendRequestHeaders: true + aclMatched: true + aclNotMatched: true +---- + diff --git a/docs/modules/ROOT/pages/troubleshooting/puid-pgid.adoc b/docs/modules/ROOT/pages/troubleshooting/puid-pgid.adoc new file mode 100644 index 0000000..889799a --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/puid-pgid.adoc @@ -0,0 +1,16 @@ +[#no-puid-pgid] += PUID and PGID support + +The OliveTin container image does not use the PUID and PGID convention to specify which user the container should run as - this is a convention that was popularized by linuxserver.io, because their container images use supervisord. Instead, simply use the `--user` argument when defining the container, to change the user OliveTin runs as. + +* link:https://docs.linuxserver.io/general/understanding-puid-and-pgid[LSIO documentation for PUID and PGID, that says that `--user` is the same thing] + +An example is shown below; + +[source,shell] +.Using --user +---- +user@host: docker create --name olivetin -p 1337:1337 -v /etc/OliveTin/:/config:ro docker.io/jamesread/olivetin --user container:container +user@host: docker start olivetin +---- + diff --git a/docs/modules/ROOT/pages/troubleshooting/server-diagnostics.adoc b/docs/modules/ROOT/pages/troubleshooting/server-diagnostics.adoc new file mode 100644 index 0000000..4ff61aa --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/server-diagnostics.adoc @@ -0,0 +1,69 @@ +[#server-diagnostics] += Server diagnostics + +OliveTin has a useful feature to gather information about your installation when you have a support request. If you are able to provide **server diagnostics**, this generally helps others help you a lot. **Server diagnostics** does NOT send any information to the developers or anybody else—it's simply text—**copy and paste it** to where someone is trying to help you! + +[source,yaml] +.Example server diagnostics output +---- +### SOSREPORT START (copy all text to SOSREPORT END) +# Build: +commit: nocommit +version: dev +date: nodate + +# Runtime: +os: linux +osreleaseprettyname: PRETTY_NAME="Fedora Linux 37 (Workstation Edition)" +arch: amd64 +incontainer: false +lastbrowseruseragent: "" + +# Config: +countofactions: 7 +loglevel: INFO + +### SOSREPORT END (copy all text from SOSREPORT START) +---- + +The markers and field names above reflect what OliveTin prints; copy everything from the start marker through the end marker. + +You can then copy and paste this text into a GitHub issue, discussion, Discord chat, or wherever else someone might be helping you. + + +== How do I generate server diagnostics? + +OliveTin needs to be able to start and its API needs to be functional. Once OliveTin is started, open this URL in a browser (replace the host and port with yours): + +`http://myserver:1337/api/sosreport` + +The `/api/sosreport` path is the HTTP endpoint OliveTin uses for server diagnostics. + +== Optional: Allow insecure (but easy) dumping to the browser + +There is a configuration option you can set in your `config.yaml` that allows you to easily dump server diagnostics to your browser when visiting the API. This is turned off by default, as you should not allow anybody to request diagnostics at any time they like, but you can enable this option temporarily to easily get access to the text in your browser. + +[source,yaml] +.`config.yaml` +---- +InsecureAllowDumpSos: true +---- + +== Default: Diagnostics dump to logs + +You should get a simple JSON message saying something like; + +---- +alert: "Your SOS Report has been logged to OliveTin logs." +---- + +The exact wording may match your OliveTin version. + +If you see this, great! The actual contents of the server diagnostics are not returned to your browser for security reasons (guests could get info about your installation, etc). + +To find the diagnostics text depends on how you are running OliveTin. If you are running in a container, then try `docker logs olivetin` (where `olivetin` is your container name). If you are running using systemd, then try `journalctl -eu OliveTin`. + +== What if I cannot even get to the OliveTin API? + +OliveTin's web interface doesn't need to be working to get server diagnostics (the web interface and the API are separate). However, if OliveTin won't even start, or you cannot reach the API, then server diagnostics cannot be generated. Please specify that when you ask for help. + diff --git a/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc b/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc new file mode 100644 index 0000000..4a66ebc --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/service-logs.adoc @@ -0,0 +1,139 @@ +[#service-logs] += Service logs (OliveTin process troubleshooting) +:experimental: + +**Service logs** are the lines OliveTin prints while it runs—startup messages, configuration load, errors, and action output. They are separate from xref:troubleshooting/browser-console-logs.adoc[browser console logs], which come from the WebUI in your browser. + +When something fails on the server (OliveTin will not start, actions error, API or auth problems), sharing **recent service log output** helps others see what the process reported. You do **not** need to be a Linux expert to copy logs from Docker, Podman, or `journalctl`. + +TIP: Before posting publicly, scan the log text for passwords, API tokens, cookies, or internal hostnames. Remove or replace those strings if needed. If you enable xref:troubleshooting/log-debug-options.adoc[extra debug logging], output can be more sensitive than usual. + +[#where-logs-go] +== Where logs come from + +How you read logs depends on how OliveTin is installed: + +* **Container (Docker / Podman):** logs are what the container runtime captured from OliveTin’s standard output. +* **systemd service:** logs are stored by the system journal for the `OliveTin` unit. +* **Manual / binary** (for example `./OliveTin` in a terminal): messages print to that terminal—copy them from there, or redirect output to a file when testing. +* **Windows (binary or service):** OliveTin writes process logs to a file under `%ProgramData%\OliveTin\logs\` by default. You can override this with xref:install/windows_service.adoc#windows-service-logs[`serviceLogs.directory`] in `config.yaml`. + +[#windows] +== Windows (binary or service) + +By default, OliveTin on Windows writes process logs to: + +[source] +---- +%ProgramData%\OliveTin\logs\OliveTin-service-.log +---- + +To use a custom directory (for portable installs), set in `config.yaml`: + +[source,yaml] +---- +serviceLogs: + directory: C:\Path\To\Logs\ +---- + +See xref:install/windows_service.adoc#windows-service-logs[Windows service logs directory] for details. This setting is ignored on non-Windows platforms. + +[#manual-binary] +== Manual or binary run + +If you start OliveTin directly in a terminal window, **scroll back** and copy the text from startup through the error, or run it again and capture output: + +[source,shell] +---- +./OliveTin 2>&1 | tee olivetin-run.log +---- + +That writes everything to `olivetin-run.log` while still showing it on screen. Stop OliveTin before sharing, then attach or paste the relevant part of the file. + +[#docker-and-podman] +== Docker and Podman + +Use the **same container name** you chose when you created the container (examples below use `olivetin`; yours may differ). + +.Show recent log lines (last ~200 lines) +[source,shell] +---- +docker logs --tail 200 olivetin +---- + +With Podman, use the same pattern: + +[source,shell] +---- +podman logs --tail 200 olivetin +---- + +If OliveTin **crashes on startup** or you need the **full story from a restart**, show logs **after** reproducing the problem—either capture enough lines, or use a time window. + +.Limit output to the last few minutes (adjust the time as needed) +[source,shell] +---- +docker logs --since 10m olivetin +---- + +To **watch** logs live while you trigger an issue in another window: + +[source,shell] +---- +docker logs -f olivetin +---- + +Press kbd:[Ctrl+C] to stop following; that does not stop OliveTin. + +[#docker-compose] +== Docker Compose + +From the directory that contains your `compose.yaml` (or `docker-compose.yml`): + +[source,shell] +---- +docker compose logs --tail 200 olivetin +---- + +Use the **service name** from the compose file, not necessarily the container name. Add `-f` to follow logs live, same idea as above. + +[#systemd] +== systemd (native Linux package) + +Show whether the service is running and recent status: + +[source,shell] +---- +systemctl status OliveTin +---- + +Read the journal for the OliveTin unit (scroll with arrow keys, quit with kbd:[q]): + +[source,shell] +---- +journalctl -eu OliveTin +---- + +For a **time-bounded** slice (for example after a restart or failed action): + +[source,shell] +---- +journalctl -eu OliveTin --since "30 minutes ago" +---- + +[#what-to-capture] +== What to capture + +. Reproduce the problem if you can (restart OliveTin, click the action, etc.), then collect logs **immediately after**. +. Include lines from **startup** through the **error**—not only the last one line. +. If the log is huge, prefer `--since` / `--tail` so the excerpt stays readable. +. Note your **install type** (Docker, Podman, Compose, systemd) and OliveTin **version** if you know it (from the WebUI footer, xref:troubleshooting/server-diagnostics.adoc[server diagnostics], or startup text). + +For deeper server-side detail, you can temporarily enable xref:troubleshooting/log-debug-options.adoc[LogDebugOptions]—then capture logs again with those settings in mind. + +[#share-the-logs] +== Share the logs + +Paste the text into a xref:troubleshooting/wheretofindhelp.adoc[Discord or GitHub support] message (or attach a `.txt` file if it is long). Together with **how you run OliveTin** (direct port, reverse proxy, container flags) and **what you did** before the error, service logs make backend issues much easier to diagnose than a screenshot of the WebUI alone. + +For WebUI-only behaviour (blank page, buttons not loading), also capture xref:troubleshooting/browser-console-logs.adoc[browser console logs] when relevant. diff --git a/docs/modules/ROOT/pages/troubleshooting/wheretofindhelp.adoc b/docs/modules/ROOT/pages/troubleshooting/wheretofindhelp.adoc new file mode 100644 index 0000000..7ab3f3d --- /dev/null +++ b/docs/modules/ROOT/pages/troubleshooting/wheretofindhelp.adoc @@ -0,0 +1,4 @@ +[#support] += Where to find help + +include::partial$support.adoc[] diff --git a/docs/modules/ROOT/pages/upgrade/2k3k.adoc b/docs/modules/ROOT/pages/upgrade/2k3k.adoc new file mode 100644 index 0000000..3520855 --- /dev/null +++ b/docs/modules/ROOT/pages/upgrade/2k3k.adoc @@ -0,0 +1,106 @@ += Understanding OliveTin 2k vs 3k + +NOTE: OliveTin 3k is basically "rebuilding OliveTin for the future" - and aims to have high compatibility with OliveTin 2k while people migrate. However, as we all know, stuff will break when 3k is new - so please be patient, and report issues as you find them! OliveTin 2k isn't going anywhere anytime soon! + +== Quick summary of key points + +|=== +| Release Series | Versioning Scheme | Supported Until + +| **OliveTin 2k** + +eg: 2022.11.11 + +eg: 2023.04.15 +| Calendar Versioning (calver) +| At least 31st December 2028 (bug fixes and security updates only) + +| **OliveTin 3k** + +eg: 3000.0.0 + +eg: 3000.1.0 +| Semantic Versioning (semver) +| Ongoing - new features and updates +|=== + +Users who are upgrading are encouraged to try OliveTin 3k, but understand that it represents several major technology changes, so subtle things might change or break in the first few releases. The objective is to maintain as much compatibility as is pragmatically possible, and highlight any breaking changes clearly. + +The main reason for OliveTin 3k is to allow for major technology changes inside the project that enable new features at a later date. + +== Understanding why a change was necessary - from calver to semver + +The original version of OliveTin was released in May 2021, and it used a versioning system called link:https://calver.org[calver] (Calendar Versioning). For those of you unfamiliar with the calver standard, it means that instead of using 1.0.0 as the first version, the first version was actually called **2021-05-19**. One of the benefits of this approach is that you can determine how old the version is just by looking at the version number alone. Unfortunately this is the only real benefit. + +However, this worked just fine for a while, but as OliveTin grew, it became clearer that most packaging systems and users were more accustomed to the more traditional link:https://semver.org[semver] (Semantic Versioning) system. As a stopgap measure, OliveTin switched the format of it's versioning to use dots (".") instead of dashes ("-") in an attempt to make it look more like a traditional semver version. This resulted in a change with version **2022.11.11** (November 11th, 2022 - what a cool date!). + +As OliveTin continued to mature, and more features were added, it became clear that there were some major architecture and library choices that were no longer ideal. Switching these out (like the configuration library, Viper), is a really major change, and isn't something that is good to do without a lot of users being very aware of it. In May 2025, a proposal was launched to switch OliveTin to a more traditional semver versioning system. You can view the detail of that proposal here: https://github.com/OliveTin/OliveTin/discussions/591 + +In short, the benefits of switching to semver are; + +- Easier to understand versioning for most users +- Allows for major technology changes without confusing users (like the Viper change) +- Better compatibility with packaging systems +- Better reflects the maturity of versions, and allows people to opt out of minor patches for example + +This proposal seemed to be positively received by the community, and so the James (founder) decided to move forward with the change. + +== What were the major reasons for the technology changes in OliveTin 3k? + +OliveTin 2k has the following technology choices which limited it's potential for stability, maintainability and growth; + +- The communication between the client and the server uses gRPC, which is then fronted by a REST API and messages are very hackily wrapped in JSON. This has worked remarkably well, but the web has moved on to offer websockets and streaming in a far better way, and connectrpc solves many of these challenges neatly and elegantly. +- The configuration library, Viper, is a defacto standard in Go several years ago - big projects like kubectl and others use it. However the library is extremely heavyweight (lots of dependencies), it has lots of open issues, and doesn't support some commonly requested features like configuration file splitting and inclusion - which would really help user's manage large configurations. +- The entity management inside OliveTin was written in a weekend, and is just a large map of strings inside OliveTin2k, again this has worked surprisingly well, but there are lots of interesting use cases with entities that would be much easier to implement with a proper typed backend - and far better mapping between actions and entities (which is extremely horrible in OliveTin 2k). +- The javascript in OliveTin 2k is raw web components, based on the "new" (at the time) specs from 2011. It's fast and standards compliant, but it's also resulted in a LOT of spaghetti code that is very hard to test and maintain. + +== Why version 3000.0.0 (and not 3.0.0)? + +If you look through the original proposal, it was to create a new package called "OliveTin-semver", and start the versioning at 1.0.0. However, after some more thought, it was decided that this would actually create a lot of confusion, as users would have to figure out if they were using "OliveTin" or "OliveTin-semver" - and it duplicates the package in in places like Linux distributions, which is not a good idea. + +However, starting with version "2.0.0" after versions like "2022.11.11" would break the logic of lots of update tools and scripts that people use. This is because major version "2" is below "2022", and so the version ordering, and logic would always think that version 2022.x.x is newer than 2.x.x. This would be very confusing for users, and would likely lead to people not updating, or even downgrading by mistake. + +To avoid this confusion, it was decided to jump straight to version "3000.0.0" - which is clearly above "2022.11.11" and any other 2k version. This way, users can easily see that 3000.x.x is newer than any 2k version, and it avoids any confusion with version ordering. + +- OliveTin versions like 2022.11.11 and 2023.04.15 are part of "**OliveTin 2k**" (2000 series) and use a **calendar versioning** (calver) scheme. These versions are stable and will continue to receive updates and support but will not receive new features. + - There is no plan to stop supporting OliveTin 2k versions until **at least 31st December 2028** (and I'll probably keep extending that date, but it's good to have a cutoff somewhere). +- OliveTin versions starting from 3000.0.0 and onwards are part of "**OliveTin 3k**" (3000 series) and also use a **semantic versioning scheme**. These versions will receive new features, improvements, and updates. + +== Should I automatically update to OliveTin 3k? + +Users are encouraged to try OliveTin 3k, but understand that it represents several major technology changes, so subtle things might change or break in the first few releases. Please remember that OliveTin 2k will continue to be supported and receive updates until at least 31st December 2028, so there is no rush to upgrade. + +The developers of this project have quite a lot of test infrastructure around OliveTin 3k (more than was possible with 2k), and would like to **strongly urge problems with updates to be reported, so that they can be fixed when possible**. + +=== How do I stop my OliveTin 2k containers from upgrading to OliveTin 3k automatically? + +Change your `latest` tag to `latest-2k` in your container definitions. + +3 tags now exist for container images and container registries; + +- `olivetin/olivetin:latest-2k` - This tag will always point to the latest OliveTin 2k version (eg 2025.11.11) +- `olivetin/olivetin:latest-3k` - This tag will always point to the latest OliveTin 3k version (eg 3000.2.0) +- `olivetin/olivetin:latest` - This tag will always point to the latest OliveTin version (currently 3k) + +xref:install/container.adoc[More information about installing OliveTin in containers] + +=== What does GitHub "latest" point to? + +GitHub's "latest" release tag isn't super helpful. It currently points to the "release that was pushed last". So sometimes this will be 2k, and sometimes this will be 3k. We're trying to find a way to fix this. + +== What are the changes I need to make to upgrade from OliveTin 2k to 3k? + +=== OliveTin Configuration files + +To date, no changes are required to the configuration file (and configs are being automatically migrated internally to OliveTin 3k - without rewriting the original config file). + +However, it is possible that some configuration changes will be necessary as we learn more about people's setups with OliveTin 2k. + +=== Reverse Proxy configurations + +If you are using a reverse proxy (like Nginx, Apache, Caddy, Traefik etc), then you will need to make some changes to your configuration. This is because OliveTin 3k uses websockets for communication between the client and the server, instead of gRPC over HTTP/2. Many proxies use short default timeouts and will close websocket connections; increase the websocket or proxy timeout in your proxy config to avoid disconnects (OliveTin will reconnect automatically). See xref:troubleshooting/err-websocket-connection.adoc#reverse-proxies-will-close-websockets[Reverse proxies will close websockets] for more. + +Please refer to the [Reverse Proxies](reverse-proxies/intro.adoc) section of the documentation for updated configuration examples for various reverse proxies. + +- xref:reverse-proxies/nginx.adoc#upgrade3k[Nginx: Updating your configuration from OliveTin 2k to OliveTin 3k] + diff --git a/docs/modules/ROOT/pages/upgrade/github_latest.adoc b/docs/modules/ROOT/pages/upgrade/github_latest.adoc new file mode 100644 index 0000000..376da39 --- /dev/null +++ b/docs/modules/ROOT/pages/upgrade/github_latest.adoc @@ -0,0 +1,28 @@ += Warning - GitHub Latest + +GitHub has a handy feature where releases can be marked as "latest". However, it does not understand that some projects, like OliveTin, have **two** active release streams, xref::upgrade/2k3k.adoc[2k and 3k]. Both of those individual streams can have a "latest" - the latest 2k version and the latest 3k version. Unfortunately, GitHub does not provide a way to mark a release at "latest-2k" or "latest-3k" - it only has "latest". + +It often happens that a 2k release will come out AFTER a 3k release, and if we left GitHub to do what it does by default, the "latest" URL would be the 2k version - and then when a 3k release goes out, the "latest" URL would be point to a 3k version. This is probably not what you want - "latest" flipping between the "last release that went out". You want it to point to the latest 3k version. + +Therefore, the OliveTin project has disabled "latest" releases for all the 2k versions, so that the GitHub "latest" URL will always be the latest 3k version. + +This means that **GitHub "latest" URLs will now always point to a 3k version** (eg: `https://github.com/OliveTin/OliveTin/releases/latest/download/OliveTin_linux_amd64.rpm` = 3k version). + +[WARNING] +If you have been using a script to download the latest version of OliveTin, **you will need to accept 3k, OR update your script to use the new workaround described below.** + +== Container Tags are unaffected + +Container registries allow any number of arbitary tags, so containers DO have a "latest-2k" and "latest-3k" tags. The breaking change described above is only for GitHub release URLs. So, you can still pull the latest 2k or 3k version of the container if you want to. Learn more about available tags in the xref::install/container.adoc[Container Installation Guide]. + +== What if I want to use the "latest 2k" download URL in my scripts? + +It is understood that some users will want to hard-code a "wget URL" for the latest 2k version into their scripts. Unfortunately the GitHub "latest" URL is now no longer a reliable way to do this. However, the OliveTin project has provided you with a workaround. + +In the repository OliveTin/update-check.olivetin.app, there is a Python script that will return the latest 2k version number. You can use this to construct a URL for the latest 2k version. The file `versions.json` also contains the latest 3k versions, and provides download URLs and checksums for each package. + +* link:https://raw.githubusercontent.com/OliveTin/update-check.olivetin.app/refs/heads/main/versions.json[versions.json in the update-check.olivetin.app repository] + +This repository is updated automatically whenever a new 2k or 3k version is released, so you can be sure that the URL you use will always point to the latest version. Please check this repository README for details on how to use this file in your scripts to get the relevant download URLs; + +* link:https://github.com/OliveTin/update-check.olivetin.app/tree/main[update-check.olivetin.app README] \ No newline at end of file diff --git a/docs/modules/ROOT/pages/upgrade/upgrade_notes.adoc b/docs/modules/ROOT/pages/upgrade/upgrade_notes.adoc new file mode 100644 index 0000000..8ca32ab --- /dev/null +++ b/docs/modules/ROOT/pages/upgrade/upgrade_notes.adoc @@ -0,0 +1,67 @@ +[#upgrade-notes] += Upgrade Notes + +OliveTin releases are published to GitHub, and the release notes are contained there. This page includes a summary of "Upgrade Notes" for breaking changes between releases. + +== 2024.08.14 + +=== Navigation change - Subpaths no longer supported + +In the past, OliveTin supported subpaths in the URL, for example, `http://yourServer:1337/olivetin/` would load the OliveTin web interface. This was convenient for people who wanted to run OliveTin on a subpath of their domain for some reason, but it actually creates a lot of complexity in the code, and makes it harder to maintain. One particular change that was wanted was to be able to link to specific pages in OliveTin, for example, `http://yourServer:1337/logs` or `http://yourServer:1337/myDashboard/myFolder` - this became incredibly difficult to implement with the subpath support. + +Therefore, OliveTin no longer supports subpaths in the URL. If you have been using OliveTin with a subpath, you will need to change your configuration to use a subdomain or a different port. + +== 2024.04.09 + +=== Themes Directory (for theme users) + +==== Background + +Until now, OliveTin has served the themes directory from the "webui" directory, normally `/var/www/olivetin/themes` on most installations. If you wanted to install a theme, you would put the theme in to that directory. + +This was a bit cumbersome, because OliveTin treats the content of the "webui" directory as disposable / part of the system, whereas themes are much more like "user data" and "configuration". It also meant that people using Linux Containers had to bind-mount a separate directory for themes. + +==== Upgrade + +Now themes are stored in the "configdir" (wherever OliveTin finds it's config file, eg `/config` in containers), under the subdirectory `custom-webui/themes`. OliveTin will try to create the `custom-webui` folder in your configdir if it doesn't find it. + +The advantage of this change is that themes are stored with your config, as part of your data. + +To upgrade, simply move any themes you might have into your configdir, under `custom-webui/themes/`. eg: + +[source,yaml] +---- +. +├── config.yaml +├── custom-webui +│ └── themes +│ └── custom-icons +│ ├── icon.png +│ └── theme.css +├── entities +│ ├── containers.json +│ ├── heating.yaml +│ ├── servers2.yml +│ ├── servers.yaml +│ └── systemd_units.json +└── installation-id.txt +---- + +=== Theme Asset Paths (for theme developers) + +==== Background + +OliveTin had to send the theme name to the browser, so that some javascript could then request `http://yourServer:1337/themes/myTheme/theme.css`, and load it as a stylesheet. This had the following problems; + +. This was slow (could take up to a second) +. This creates a "flash" in the browser, as the new theme.css is loaded over the top of the existing stylesheet. +. Browsers could not cache this theme.css with the page load. + +==== Upgrade + +The new behavior is that OliveTin will always try to load `http://yourServer:1337/theme.css` as part of the static HTML that is sent to the browser. This means that regular caching can cache the theme.css, and this effectively elliminates the slowness and "flashing" when the browser renders the page. + +Internally, OliveTin maps `http://yourServer:1337/theme.css` to the `theme.css` of whatever the theme is set to with `themeName`, for example, it could map to `themes/myTheme/theme.css`. + +As a theme developer, normally you would reference a background image, or similar using `./background.png`, but OliveTin is loading the theme.css as the directory root, and the themes' assets from the custom-webui theme directory. Therefore you need to update paths to be like; `background-image: "/custom-webui/themes/myTheme/background.png`. + diff --git a/docs/modules/ROOT/partials/action_examples/actionHeader.adoc b/docs/modules/ROOT/partials/action_examples/actionHeader.adoc new file mode 100644 index 0000000..76c9f12 --- /dev/null +++ b/docs/modules/ROOT/partials/action_examples/actionHeader.adoc @@ -0,0 +1,13 @@ + +[%header, cols="1,4"] +|=== +| Installation type | Difficulty to do this +| Running as a **Systemd service** | {systemd} +| Running in a **container** | {container} +|=== + +== Example config.yaml + +// Unset variables +:container!: +:systemd!: diff --git a/docs/modules/ROOT/partials/action_examples/ssh_intro.adoc b/docs/modules/ROOT/partials/action_examples/ssh_intro.adoc new file mode 100644 index 0000000..7826427 --- /dev/null +++ b/docs/modules/ROOT/partials/action_examples/ssh_intro.adoc @@ -0,0 +1,2 @@ +This is probably one of the most useful things OliveTin is used for - just plain old SSH, which allows it to easily connect from a container to any server running on your network to run commands. This is also the preferred method of running commands on the server that is hosting the OliveTin container image as well. + diff --git a/docs/modules/ROOT/partials/action_execution/onfileindir_arguments.adoc b/docs/modules/ROOT/partials/action_execution/onfileindir_arguments.adoc new file mode 100644 index 0000000..c2f4347 --- /dev/null +++ b/docs/modules/ROOT/partials/action_execution/onfileindir_arguments.adoc @@ -0,0 +1,17 @@ +== File in dir arguments + +|=== +| Predefined Argument | Example + +| `filepath` | /Downloads/txt1.txt +| `filedir` | /Downloads +| `filename` | test1.txt +| `fileext` | .txt +| `filesizebytes` | 100 +| `filemode` | 0644 +| `filemtime` | 2024-04-27 20:09:42.465235047 +0100 BST +| `fileisdir` | false +|=== + +Like all arguments, OliveTin also passes these arguments as <> if this is better for your use case. + diff --git a/docs/modules/ROOT/partials/api/start_action_methods.adoc b/docs/modules/ROOT/partials/api/start_action_methods.adoc new file mode 100644 index 0000000..48a6e63 --- /dev/null +++ b/docs/modules/ROOT/partials/api/start_action_methods.adoc @@ -0,0 +1,30 @@ +There are 4 main methods to start an action in OliveTin, which can be used in scripts or other applications. These methods allow you to trigger actions either immediately or wait for them to complete, and they can be accessed via HTTP requests. + +.Webhook/API reference table +[%header] +|====================================================== +| Function | HTTP Method | Request Type (How to select an action) | Response Type + +| xref:api/method_StartAction.adoc[StartAction] +| POST +| xref:api/start_action.adoc#api-request-obj[OliveTin request object] +| xref:api/start_action.adoc#api-response-trackingid[Execution Tracking ID] + +| xref:api/method_StartActionByGet.adoc[StartActionByGet] +| GET +| xref:api/start_action.adoc#api-request-idurl[Action ID in the URL] +| xref:api/start_action.adoc#api-response-trackingid[Execution Tracking ID] + +| xref:api/method_StartActionAndWait.adoc[StartActionAndWait] +| POST +| xref:api/start_action.adoc#api-request-obj[OliveTin request object] +| xref:api/start_action.adoc#api-response-logentry[Log Entry (waits for the action to finish)] + +| xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait] +| GET +| xref:api/start_action.adoc#api-request-idurl[Action ID in the URL] +| xref:api/start_action.adoc#api-response-logentry[Log Entry (waits for the action to finish)] + +|====================================================== + +You can also browse these methods in the link:htts://docs.olivetin.app/api/swagger/[OpenAPI/Swagger] docs if you prefer. diff --git a/docs/modules/ROOT/partials/args/reject-null.adoc b/docs/modules/ROOT/partials/args/reject-null.adoc new file mode 100644 index 0000000..e088875 --- /dev/null +++ b/docs/modules/ROOT/partials/args/reject-null.adoc @@ -0,0 +1,9 @@ +== Rejecting empty values (null) + +```yaml +actions: + - shell: echo "Hello {{ name }}!" + arguments: + - name: name + rejectNull: true` +``` diff --git a/docs/modules/ROOT/partials/config-start.adoc b/docs/modules/ROOT/partials/config-start.adoc new file mode 100644 index 0000000..aa227d4 --- /dev/null +++ b/docs/modules/ROOT/partials/config-start.adoc @@ -0,0 +1,2 @@ +[source,yaml] +.`config.yaml` diff --git a/docs/modules/ROOT/partials/container_socket.adoc b/docs/modules/ROOT/partials/container_socket.adoc new file mode 100644 index 0000000..0b08e42 --- /dev/null +++ b/docs/modules/ROOT/partials/container_socket.adoc @@ -0,0 +1,74 @@ +== Setup if running inside a container + +You can control other containers, when running OliveTin inside a container +itself, however you need to do some extra setup when creating the OliveTin +container. + +=== Ensure your container has permissions to control docker + +You have two alternatives to allow OliveTin (running inside a container) to talk to the Docker daemon through the bind-mounted socket. Pick one: + +==== Option 1 — Use `--privileged` (simplest) + +NOTE: Simplest for most users. Podman does not have this requirement. + +- Run the container with `--privileged` and as `root` (eg `--user root`). +- This avoids user/group permission issues on `/var/run/docker.sock`. + +If you are getting "permission denied" errors it is probably because OliveTin runs as user UID 1000 by default, which is not allowed by your docker host. Running with `--user root` under `--privileged` resolves this quickly. Note that xref:troubleshooting/puid-pgid.adoc[PUID and PGID variables will not work]. + +==== Option 2 — Run as non-root in the host `docker` group (no `--privileged`) + +Use the standard Docker guidance to manage Docker as a non-root user (becoming a member of the `docker` group) and match the group's GID inside the container so the process can access the socket permissions. + +- Docs: https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user[Manage Docker as a non-root user] +- Find the `docker` group GID on the host, for example using `getent group docker`. +- Run the container with your user UID and the `docker` group GID, and bind-mount the socket. Using Compose: + +[source,yaml] +.docker-compose.yml +---- +services: + olivetin: + container_name: olivetin + image: jamesread/olivetin + user: ${UID}:${docker_group_id} + volumes: + - /var/run/docker.sock:/var/run/docker.sock +---- + +Where `UID` and `docker_group_id` are provided via your shell environment or a `.env` file next to your Compose file, for example: + +[source,bash] +.env +---- +UID=1000 +docker_group_id=995 +---- + +This allows you to run the container as a non-root user, while still allowing access to `/var/run/docker.sock`. + +=== Pass the docker socket into the container + +. Pass `/var/run/docker.sock` as a bind mount to the container when creating it, eg: + ++ +---- +docker create --privileged --user root -v /var/run/docker.sock:/var/run/docker.sock ...additional args here... +---- ++ +Or, using the `docker run` syntax; ++ +---- +docker run --privileged --user root -v /var/run/docker.sock:/var/run/docker.sock --name OliveTin jamesread/olivetin +---- ++ +. The official x86_64 docker container comes with the `docker` client pre-installed. If you are using `arm` or and `arm64` container, you will need to add Docker yourself. ++ +xref:reference/containerInstallPackages.adoc[How to install additional packages in the container] ++ +NOTE: The reason that the `arm` and `arm64` containers do not include docker, is that when these images are cross-compiled at build time, it takes FOREVER because we have to emulate arm. + +After you have passed the socket into the container (and optionally installed docker), you should be able to setup docker actions like it's shown in the example <>. + + diff --git a/docs/modules/ROOT/partials/earlydoc.adoc b/docs/modules/ROOT/partials/earlydoc.adoc new file mode 100644 index 0000000..1e12b1f --- /dev/null +++ b/docs/modules/ROOT/partials/earlydoc.adoc @@ -0,0 +1,2 @@ + +NOTE: This page is marked as "earlydoc", which means that it more of a collection of notes and an early draft before this page turns into good documentation later on. It is hoped that this early form of documentation is useful to you, but please understand that most documentation pages are higher quality than this. If you have suggestions or comments, please do get in contact or consider contributing your suggestions to the OliveTin documentation. diff --git a/docs/modules/ROOT/partials/install/container.adoc b/docs/modules/ROOT/partials/install/container.adoc new file mode 100644 index 0000000..cf97445 --- /dev/null +++ b/docs/modules/ROOT/partials/install/container.adoc @@ -0,0 +1,6 @@ +[NOTE] +==== +OliveTin is supported when run as a Linux Container in many different ways, and lot of OliveTin users will assume that a Linux Container is the best way to install OliveTin because Linux Containers are really popular... + +However, it is very common that some OliveTin use cases become overcomplicated when a container is used, compared to running as a native service. Read the <> document to understand if a container or a service is right for your use case. +==== diff --git a/docs/modules/ROOT/partials/install/container_registries.adoc b/docs/modules/ROOT/partials/install/container_registries.adoc new file mode 100644 index 0000000..657c27b --- /dev/null +++ b/docs/modules/ROOT/partials/install/container_registries.adoc @@ -0,0 +1,6 @@ + +The image is pushed to the following registries, pick the one that you prefer - the container images are identical so you only need one; + +* Dockerhub container image: link:https://hub.docker.com/r/jamesread/olivetin/[docker.io/jamesread/olivetin] +* GitHub container image: link:https://ghcr.io/olivetin/olivetin[ghcr.io/jamesread/olivetin] + diff --git a/docs/modules/ROOT/partials/install/post_container.adoc b/docs/modules/ROOT/partials/install/post_container.adoc new file mode 100644 index 0000000..ec527cb --- /dev/null +++ b/docs/modules/ROOT/partials/install/post_container.adoc @@ -0,0 +1,63 @@ +== Post installation (container) + +You will need to write a basic configuration file before OliveTin will startup. + +Create a basic config file at `/etc/OliveTin/config.yaml` - the exact path depends on what directory you specified in the bind mount container creation in the last step. Note that the file must be called `config.yaml`, and `config.yml` or `mystuff.yaml` would not work. You can download a sample configuration file like this if you like; + +[source,shell] +.Download the sample config.yaml file to get you started. +---- +user@host: cd /etc/OliveTin/ +user@host: curl -O https://raw.githubusercontent.com/OliveTin/OliveTin/main/config.yaml +---- + +The file contents should look something like this; + +.The most simple `config.yaml` file. +[source,yaml] +---- +actions: + - title: "Hello world!" + shell: echo 'Hello World!' +---- + +If you are running a firewall on your server, like firewalld, you will need to +open port 1337; + +== Configure your firewall + +[tabs] +==== +No Firewall:: If you don't have a firewall, continue to the next section. + +FirewallD:: This is how you configure your firewalld firewall for OliveTin: ++ +[source, shell] +---- +user@host: firewall-cmd --add-port 1337/tcp --permanent +user@host: firewall-cmd --reload +---- ++ +==== + +== Start the OliveTin service + +Now that you have a configuration file, and the OliveTin container created, you are now ready to start OliveTin! + +---- +user@host: docker start olivetin +---- + +include::partial$install/to_config.adoc[] + +== Troubleshooting podman/docker installations + +If you are having problems in starting OliveTin, or OliveTin is crashing on startup, then check the logs like this; + +---- +user@host: docker logs OliveTin +---- + +For more detail on what to capture and how to share logs when asking for help, see xref:troubleshooting/service-logs.adoc[Service logs (troubleshooting)]. + +If you cannot understand the logs, or otherwise need help, see the xref:troubleshooting/wheretofindhelp.adoc[support page]. diff --git a/docs/modules/ROOT/partials/install/post_generic.adoc b/docs/modules/ROOT/partials/install/post_generic.adoc new file mode 100644 index 0000000..a6e54c2 --- /dev/null +++ b/docs/modules/ROOT/partials/install/post_generic.adoc @@ -0,0 +1,25 @@ +== Post installation + +You will need to write a basic configuration file before OliveTin will startup. + +Edit the basic config file at `config.yaml` with the following contents; + +.The most simple `config.yaml` file. +[source,yaml] +---- +actions: + - title: "Hello world!" + shell: echo 'Hello World!' +---- + +Start OliveTin, preferably via a terminal. On Unix based systems (eg MacOS, BSD, Linux, etc) you can just run `./OliveTin`. On Windows you would run `OliveTin.exe` in windows terminal. + +include::partial$install/to_config.adoc[] + +== Troubleshooting installations + +If you are having problems, OliveTin will log it's status on startup. Check the log messages in the terminal. + +For tips on capturing and sharing that output, see xref:troubleshooting/service-logs.adoc[Service logs (troubleshooting)]. + +If you cannot understand the logs, or otherwise need help, see the xref:troubleshooting/wheretofindhelp.adoc[support page]. diff --git a/docs/modules/ROOT/partials/install/post_systemd.adoc b/docs/modules/ROOT/partials/install/post_systemd.adoc new file mode 100644 index 0000000..8d4c823 --- /dev/null +++ b/docs/modules/ROOT/partials/install/post_systemd.adoc @@ -0,0 +1,53 @@ +== Post installation + +You will need to write a basic configuration file before OliveTin will startup. + +Create the following basic config file at `/etc/OliveTin/config.yaml` with the +following contents; + +.The most simple `config.yaml` file. +[source,yaml] +---- +actions: + - title: "Hello world!" + shell: echo 'Hello World!' +---- + +Now that you have a configuration file, and OliveTin is installed, start it; + +.Start the service (only needed once) +[source,shell] +---- +user@host: systemctl enable --now OliveTin +---- + +If you are running a firewall on your server, like firewalld, you will need to +open port 1337; + +[source,shell] +---- +user@host: firewall-cmd --add-port 1337/tcp --permanent +user@host: firewall-cmd --reload +---- + +include::partial$install/to_config.adoc[] + +== Troubleshooting systemd installations + +If you are having problems, you can check if OliveTin is running like this; + +[source,shell] +---- +user@host: systemctl status OliveTin +---- + +If the service has failed, scroll through the logs; + +[source,shell] +---- +user@host: journalctl -eu OliveTin +---- + +If you cannot understand the logs, or otherwise need help, see the xref:troubleshooting/wheretofindhelp.adoc[support page]. + +For more detail on what to capture and how to share logs when asking for help, see xref:troubleshooting/service-logs.adoc[Service logs (troubleshooting)]. diff --git a/docs/modules/ROOT/partials/install/to_config.adoc b/docs/modules/ROOT/partials/install/to_config.adoc new file mode 100644 index 0000000..f48e513 --- /dev/null +++ b/docs/modules/ROOT/partials/install/to_config.adoc @@ -0,0 +1,7 @@ +You should be able to browse to http://yourserver:1337 (or similar) to get to +the web interface. + +If you see the OliveTin page popup in your browser, you are good to go! Here are some helpful next steps; + +* xref:action_buttons/create_your_first.adoc[Create your first action] +* xref:config.adoc[configuration section] for a list of all configuration options. diff --git a/docs/modules/ROOT/partials/install/windows_service_logs.adoc b/docs/modules/ROOT/partials/install/windows_service_logs.adoc new file mode 100644 index 0000000..8bc14ee --- /dev/null +++ b/docs/modules/ROOT/partials/install/windows_service_logs.adoc @@ -0,0 +1,27 @@ +[#windows-service-logs] +== Process log directory + +On Windows, OliveTin writes its **process logs** (startup messages, configuration load, errors, and internal diagnostics) to a log file on disk. By default these files are stored under `%ProgramData%\OliveTin\logs\` as `OliveTin-service-.log`. + +This is separate from xref:logs/saving.adoc[action execution logs] (`saveLogs`), which persist command output from individual actions. + +Portable or self-contained installs often keep OliveTin, its configuration, and its logs together in one folder. Without a custom path, process logs always go to `%ProgramData%`, even when you run OliveTin from another location. + +Add a `serviceLogs` block to your `config.yaml`: + +[source,yaml] +---- +serviceLogs: + directory: ./logs/service/ +---- + +OliveTin creates the directory if it does not exist and writes a new timestamped log file there on each startup. + +If `serviceLogs.directory` is omitted, OliveTin uses the default location: `%ProgramData%\OliveTin\logs\`. + +Relative paths (for example `./logs/service/`) are resolved from the directory containing `OliveTin.exe`. This keeps portable installs self-contained when you colocate logs with the application. + +[NOTE] +==== +`serviceLogs.directory` is **Windows only**. If you set it on Linux, macOS, or in a container, OliveTin logs an error at startup and ignores the setting. On those platforms, use xref:troubleshooting/service-logs.adoc[service logs troubleshooting] for how to read process output (for example `journalctl` or container logs). +==== diff --git a/docs/modules/ROOT/partials/reverse-proxies/diagram.adoc b/docs/modules/ROOT/partials/reverse-proxies/diagram.adoc new file mode 100644 index 0000000..fb37551 --- /dev/null +++ b/docs/modules/ROOT/partials/reverse-proxies/diagram.adoc @@ -0,0 +1,19 @@ +[mermaid,png] +.Flow of an inbound network request +.... +%%{init: {'theme': 'neutral'}}%% +graph LR + A[Your Browser] -->|HTTPS 443/tcp| Z + Z["Proxy"] -->|HTTP 80/tcp| C + C["Single HTTP frontend"] + H["Prometheus"] + B["gRPC API"] + + subgraph "OliveTin service" + C -->|/api/| D[REST API] --> B + C -->|/| E[webui] + C -->|/metrics/| H + end +.... + +:proxy!: diff --git a/docs/modules/ROOT/partials/reverse-proxies/external-rest.adoc b/docs/modules/ROOT/partials/reverse-proxies/external-rest.adoc new file mode 100644 index 0000000..5728990 --- /dev/null +++ b/docs/modules/ROOT/partials/reverse-proxies/external-rest.adoc @@ -0,0 +1,10 @@ +Note, because you are changing the default path (from `/` to `/OliveTin/`), you will need to tell the OliveTin webUI where to find the API. + +You need to also set `externalRestAddress` in your config.yaml like this; + +.OliveTin config.yaml +[source,yaml] +---- +externalRestAddress: http://myserver/OliveTin +---- + diff --git a/docs/modules/ROOT/partials/support.adoc b/docs/modules/ROOT/partials/support.adoc new file mode 100644 index 0000000..8b3ef43 --- /dev/null +++ b/docs/modules/ROOT/partials/support.adoc @@ -0,0 +1,12 @@ + +When something is wrong with the **WebUI** in the browser, capturing xref:troubleshooting/browser-console-logs.adoc[browser console logs] (even as a screenshot) helps diagnose the issue. + +When something is wrong with the **OliveTin process** (startup failures, actions, API, or auth), capturing xref:troubleshooting/service-logs.adoc[service logs] from Docker, Podman, or `journalctl` helps diagnose the issue. + +To get relatively quick access to help, **Discord** is where the chat community for OliveTin is. Note that this project is a free community open source project, and it relies on volenteers to spare their free time to help you. Please be patient and polite. + +image:icons/Discord.png[inline] link:https://discord.gg/jhYWWpNJ3v[Chat on Discord] + +If nobody is online, or you're not getting the right level of support, you can raise a ticket with the project's developers on GitHub. Again, please be patient and polite. + +image:icons/GitHub.png[inline] link:https://github.com/OliveTin/OliveTin/issues/new?assignees=&labels=support&template=support_request.md&title=[Open a support request on GitHub] diff --git a/docs/modules/dev/pages/signing.adoc b/docs/modules/dev/pages/signing.adoc new file mode 100644 index 0000000..a00ab7f --- /dev/null +++ b/docs/modules/dev/pages/signing.adoc @@ -0,0 +1,78 @@ +# macOS release signing + +Release builds can sign and notarize the `darwin` binaries using [quill](https://github.com/anchore/quill) via GoReleaser. This runs on the existing Linux CI runner; no macOS runner is required. + +Signing is **optional**. If the GitHub secrets below are not all set, GoReleaser skips macOS signing and publishes unsigned binaries (the previous behaviour). + +## Prerequisites + +- An active [Apple Developer Program](https://developer.apple.com/programs/) membership. +- A **Developer ID Application** certificate (not "Apple Development" or "Mac App Distribution"). +- An [App Store Connect API key](https://appstoreconnect.apple.com/access/integrations/api) with at least **Developer** access. + +## One-time setup + +### 1. Create the signing certificate + +1. Open [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list). +2. Create a certificate of type **Developer ID Application**. +3. Download the `.cer` file and double-click it to add it to **Keychain Access** on a Mac. +4. In Keychain Access, export the certificate as a **Personal Information Exchange (`.p12`)** file. You will set an export password — remember it; this becomes `MACOS_SIGN_PASSWORD`. + +### 2. Create the notarization API key + +1. Open [App Store Connect → Users and Access → Integrations → App Store Connect API](https://appstoreconnect.apple.com/access/integrations/api). +2. Create a key with **Developer** role (or Admin). +3. Download the `.p8` file once (it cannot be downloaded again). Note the **Key ID** shown in the portal and the **Issuer ID** at the top of the API keys page. + +### 3. Base64-encode the key files + +Run on a machine that has the files (Linux or macOS): + +```sh +base64 -w0 < ./Certificates.p12 # MACOS_SIGN_P12 +base64 -w0 < ./AuthKey_XXXXXX.p8 # MACOS_NOTARY_KEY +``` + +On macOS without GNU coreutils, use `base64 -i file | tr -d '\n'`. + +### 4. Add GitHub repository secrets + +In **Settings → Secrets and variables → Actions**, create: + +| Secret | Value | +|--------|-------| +| `MACOS_SIGN_P12` | Base64 contents of the `.p12` file | +| `MACOS_SIGN_PASSWORD` | Password used when exporting the `.p12` | +| `MACOS_NOTARY_KEY` | Base64 contents of the `.p8` file | +| `MACOS_NOTARY_KEY_ID` | Key ID from App Store Connect (e.g. `ABC123DEF4`) | +| `MACOS_NOTARY_ISSUER_ID` | Issuer UUID from App Store Connect | + +All five must be present for signing to run. Any missing secret disables signing for that release. + +## Renewal + +| Item | Typical lifetime | What to do | +|------|------------------|------------| +| Developer ID Application certificate | ~5 years | Create a new certificate in the Apple portal, export a new `.p12`, update `MACOS_SIGN_P12` and `MACOS_SIGN_PASSWORD`. | +| App Store Connect API key | Does not expire, but can be revoked | Create a new key if compromised or lost; update `MACOS_NOTARY_KEY`, `MACOS_NOTARY_KEY_ID`, and optionally `MACOS_NOTARY_ISSUER_ID`. | +| Apple Developer Program | Annual subscription | Renew membership before it lapses; existing certificates stop working if the account is inactive. | + +After updating secrets, the next release on `main` (via semantic-release) will use the new credentials automatically. + +## Verifying a signed release + +On a Mac, download a `OliveTin-darwin-*.tar.gz` release artifact and run: + +```sh +tar -xzf OliveTin-darwin-arm64.tar.gz +spctl -a -vv -t execute OliveTin-darwin-arm64/OliveTin +``` + +A signed and notarized binary should report `accepted` with `source=Notarized Developer ID`. + +## Configuration reference + +- GoReleaser: `notarize.macos` in [`.goreleaser.yml`](.goreleaser.yml) +- CI secrets: [`.github/workflows/build-and-release.yml`](.github/workflows/build-and-release.yml) (`release` step) +- [GoReleaser notarization docs](https://goreleaser.com/customization/notarize/) diff --git a/examples/backupScript.sh b/examples/backupScript.sh new file mode 100755 index 0000000..a4545b2 --- /dev/null +++ b/examples/backupScript.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo "Starting backup script" + +# Demo behaviour: 50% finish in 9 seconds (within the 10s action timeout), +# 50% run for 15 seconds (typically times out). +if (( RANDOM % 2 == 0 )); then + maxFiles=9 + echo "Demo: backup will finish in 9 seconds" +else + maxFiles=15 + echo "Demo: backup will run for 15 seconds (may exceed the action timeout)" +fi + +for fileIndex in $(seq 1 "$maxFiles"); do + echo "Backing up file: $fileIndex" + sleep 1 +done + +echo "All files backed up" diff --git a/frontend/js/OutputTerminal.js b/frontend/js/OutputTerminal.js index 1526318..96e95b7 100644 --- a/frontend/js/OutputTerminal.js +++ b/frontend/js/OutputTerminal.js @@ -1,5 +1,6 @@ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' +import { WebLinksAddon } from '@xterm/addon-web-links' import { Mutex } from './Mutex.js' /** @@ -18,13 +19,25 @@ export class OutputTerminal { constructor (executionTrackingId) { this.executionTrackingId = executionTrackingId this.writeMutex = new Mutex() + const linkHandler = { + activate (event, text, _range) { + event.preventDefault() + window.open(text, '_blank') + } + } + this.terminal = new Terminal({ - convertEol: true + convertEol: true, + linkHandler, + scrollback: 10000 }) const fitAddon = new FitAddon() this.terminal.loadAddon(fitAddon) this.terminal.fit = fitAddon + + this.terminal.loadAddon(new WebLinksAddon((event, uri) => linkHandler.activate(event, uri))) + this.linkHandlerConfigured = true } async write (out, then) { diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js index 8c0200f..01397c6 100644 --- a/frontend/js/websocket.js +++ b/frontend/js/websocket.js @@ -1,43 +1,235 @@ import { buttonResults } from '../resources/vue/stores/buttonResults.js' import { rateLimits } from '../resources/vue/stores/rateLimits.js' +import { connectionState } from '../resources/vue/stores/connectionState.js' +import { + applyExecutionFinishedBindingState, + applyExecutionStartedBindingState +} from '../resources/vue/stores/bindingExecutionState.js' +import { cloneLogEntry } from '../resources/vue/utils/executionLogEvents.js' -export function initWebsocket () { - window.addEventListener('EventOutputChunk', onOutputChunk) - window.addEventListener('EventExecutionStarted', onExecutionChanged) - window.addEventListener('EventExecutionFinished', onExecutionChanged) +const RECONNECT_DELAYS_MS = [200, 1000, 2000, 4000, 8000, 16000, 32000] +const BANNER_DELAY_MS = 2000 + +let reconnectAttempt = 0 +let reconnectTimer = null +let listenersInitialized = false +let eventStreamGeneration = 0 +let eventStreamAbortController = null + +function shouldConnectEventStream () { + return window.initResponse && !window.initResponse.loginRequired +} + +export function stopEventStream () { + eventStreamGeneration++ + if (eventStreamAbortController != null) { + eventStreamAbortController.abort() + eventStreamAbortController = null + } + + if (reconnectTimer != null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + reconnectAttempt = 0 + connectionState.connected = false + connectionState.reconnecting = false + connectionState.scheduledReconnectDelayMs = 0 + connectionState.nextReconnectAt = null + connectionState.showDisconnectedBanner = false + window.websocketAvailable = false +} + +export function connectEventStreamIfNeeded () { + if (!shouldConnectEventStream()) { + stopEventStream() + return + } + + if (connectionState.connected || reconnectTimer != null) { + return + } reconnectWebsocket() } +export function initWebsocket () { + if (!listenersInitialized) { + window.addEventListener('EventOutputChunk', onOutputChunk) + window.addEventListener('EventExecutionStarted', onExecutionStarted) + window.addEventListener('EventExecutionFinished', onExecutionFinished) + window.addEventListener('pagehide', stopEventStream) + listenersInitialized = true + } + + connectEventStreamIfNeeded() +} + window.websocketAvailable = false +export function requestReconnectNow () { + if (!shouldConnectEventStream()) { + return + } + + if (connectionState.connected) { + return + } + + if (reconnectTimer != null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + reconnectAttempt = 0 + scheduleReconnect(RECONNECT_DELAYS_MS[0]) +} + +function scheduleReconnect (delayMs) { + if (reconnectTimer != null) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + connectionState.scheduledReconnectDelayMs = delayMs + connectionState.nextReconnectAt = delayMs > 0 ? Date.now() + delayMs : null + updateBannerVisibility() + reconnectTimer = setTimeout(() => { + reconnectTimer = null + reconnectWebsocket() + }, delayMs) +} + +function updateBannerVisibility () { + if (connectionState.connected) { + connectionState.showDisconnectedBanner = false + return + } + + connectionState.showDisconnectedBanner = connectionState.scheduledReconnectDelayMs >= BANNER_DELAY_MS +} + async function reconnectWebsocket () { - if (window.websocketAvailable) { + if (!shouldConnectEventStream()) { + return + } + + if (connectionState.connected) { + return + } + + const streamGeneration = ++eventStreamGeneration + if (eventStreamAbortController != null) { + eventStreamAbortController.abort() + } + eventStreamAbortController = new AbortController() + + connectionState.reconnecting = true + connectionState.connected = false + if (connectionState.disconnectedAt == null) { + connectionState.disconnectedAt = Date.now() + } + connectionState.nextReconnectAt = null + connectionState.scheduledReconnectDelayMs = 0 + + try { + window.websocketAvailable = true + const stream = window.client.eventStream({}, { signal: eventStreamAbortController.signal }) + connectionState.connected = true + connectionState.reconnecting = false + connectionState.disconnectedAt = null + connectionState.nextReconnectAt = null + connectionState.scheduledReconnectDelayMs = 0 + connectionState.showDisconnectedBanner = false + for await (const e of stream) { + if (streamGeneration !== eventStreamGeneration) { + return + } + if (reconnectAttempt !== 0) { + reconnectAttempt = 0 + } + handleEvent(e) + } + } catch (err) { + if (streamGeneration !== eventStreamGeneration) { + return + } + console.error('Websocket connection failed: ', err) + } + + if (streamGeneration !== eventStreamGeneration) { + return + } + + window.websocketAvailable = false + connectionState.connected = false + connectionState.reconnecting = false + connectionState.disconnectedAt = connectionState.disconnectedAt ?? Date.now() + + const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)] + reconnectAttempt++ + console.log('Reconnecting websocket in ' + delay + 'ms...') + + if (!shouldConnectEventStream()) { + return + } + + scheduleReconnect(delay) +} + +async function refreshInitAfterConfigChange () { + if (!window.client) { return } try { - window.websocketAvailable = true - for await (const e of window.client.eventStream()) { - handleEvent(e) + window.initResponse = await window.client.init({}) + + if (typeof window.updateHeaderFromInit === 'function') { + window.updateHeaderFromInit() } } catch (err) { - console.error('Websocket connection failed: ', err) + console.error('Failed to refresh config from server after EventConfigChanged:', err) } +} - window.websocketAvailable = false - console.log('Reconnecting websocket...') +async function handleConfigChangedEvent (j) { + await refreshInitAfterConfigChange() + window.dispatchEvent(j) +} + +const eventCaseToTypeName = { + entityChanged: 'EventEntityChanged', + configChanged: 'EventConfigChanged', + executionFinished: 'EventExecutionFinished', + executionStarted: 'EventExecutionStarted', + outputChunk: 'EventOutputChunk', + heartbeat: 'EventHeartbeat' } function handleEvent (msg) { - const typeName = msg.event.value.$typeName.replace('olivetin.api.v1.', '') + const eventCase = msg?.event?.case + const eventValue = msg?.event?.value + const typeName = eventCaseToTypeName[eventCase] + + if (!typeName || !eventValue) { + console.warn('Skipping websocket event with no payload:', msg) + return + } const j = new Event(typeName) - j.payload = msg.event.value + j.payload = eventValue switch (typeName) { - case 'EventOutputChunk': case 'EventConfigChanged': + handleConfigChangedEvent(j).catch((err) => { + console.error('EventConfigChanged handler failed:', err) + }) + break + case 'EventHeartbeat': + break + case 'EventOutputChunk': case 'EventEntityChanged': window.dispatchEvent(j) break @@ -62,18 +254,28 @@ function onOutputChunk (evt) { } } -function onExecutionChanged (evt) { - buttonResults[evt.payload.logEntry.executionTrackingId] = evt.payload.logEntry +export function applyExecutionLogEntry (logEntry) { + const entry = cloneLogEntry(logEntry) + if (!entry?.executionTrackingId) { + return + } - const logEntry = evt.payload.logEntry + buttonResults[entry.executionTrackingId] = entry - // Update rate limit store from logEntry if rate limit expiry datetime is provided - if (logEntry && logEntry.datetimeRateLimitExpires && logEntry.bindingId) { - // Parse datetime string "2006-01-02 15:04:05" and convert to Unix timestamp - const date = new Date(logEntry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z') - rateLimits[logEntry.bindingId] = date.getTime() / 1000 - } else if (logEntry && logEntry.bindingId) { - // Clear rate limit if not set - rateLimits[logEntry.bindingId] = 0 + if (entry.datetimeRateLimitExpires && entry.bindingId) { + const date = new Date(entry.datetimeRateLimitExpires.replace(' ', 'T') + 'Z') + rateLimits[entry.bindingId] = date.getTime() / 1000 + } else if (entry.bindingId) { + rateLimits[entry.bindingId] = 0 } } + +function onExecutionStarted (evt) { + applyExecutionLogEntry(evt.payload.logEntry) + applyExecutionStartedBindingState(evt.payload.logEntry) +} + +function onExecutionFinished (evt) { + applyExecutionLogEntry(evt.payload.logEntry) + applyExecutionFinishedBindingState(evt.payload.logEntry) +} diff --git a/frontend/main.js b/frontend/main.js index a434cce..5574e46 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -61,6 +61,14 @@ async function initClient () { window.client = createClient(OliveTinApiService, transport) window.initResponse = await window.client.init({}) + if (window.initResponse.enableCustomJs) { + const script = document.createElement('script') + script.src = '/custom-webui/custom.js' + script.async = true + script.id = 'olivetin-custom-js' + document.head.appendChild(script) + } + const i18nSettings = createI18n({ legacy: false, locale: getSelectedLanguage(), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 51cfad8..c7ef7ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,164 +9,135 @@ "version": "1.0.0", "license": "AGPL-3.0-only", "dependencies": { - "@connectrpc/connect": "^2.1.1", - "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/vue": "^1.0.4", - "@vitejs/plugin-vue": "^6.0.4", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", + "@hugeicons/core-free-icons": "^4.2.1", + "@hugeicons/vue": "^1.0.6", + "@vitejs/plugin-vue": "^6.0.7", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.14.0", + "picocrank": "^1.17.0", "standard": "^17.1.2", - "unplugin-vue-components": "^31.0.0", - "vite": "^7.3.1", - "vue": "^3.5.28", - "vue-i18n": "^11.2.8", - "vue-router": "^5.0.2" + "unplugin-vue-components": "^32.1.0", + "vite": "^8.0.16", + "vue": "^3.5.38", + "vue-i18n": "^11.4.6", + "vue-router": "^5.1.0" }, "devDependencies": { "process": "^0.11.10", - "stylelint": "^17.3.0", + "stylelint": "^17.13.0", "stylelint-config-standard": "^40.0.0" + }, + "engines": { + "node": ">=22.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.12.11", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", - "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "node_modules/@babel/generator": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@babel/generator": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.5.tgz", + "integrity": "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0-rc.5", + "@babel/types": "^8.0.0-rc.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.5.tgz", + "integrity": "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.5.tgz", + "integrity": "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.5.tgz", + "integrity": "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.0-rc.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.0-rc.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.5.tgz", + "integrity": "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0-rc.5", + "@babel/helper-validator-identifier": "^8.0.0-rc.5" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.6.tgz", - "integrity": "sha512-2YnuOp4HAk2BsBrJJvYCbItHx0zWscI1C3zgWkz+wDyD9I7GIVrfnLyrR4Y1VR+7p+chAEcrgRQYZAGIKMV7vQ==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.6", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -176,13 +147,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -196,16 +167,16 @@ "peer": true }, "node_modules/@cacheable/memory": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz", - "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz", + "integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/utils": "^2.3.3", - "@keyv/bigmap": "^1.3.0", - "hookified": "^1.14.0", - "keyv": "^5.5.5" + "@cacheable/utils": "^2.4.1", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" } }, "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { @@ -236,13 +207,13 @@ } }, "node_modules/@cacheable/utils": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz", - "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", "dev": true, "license": "MIT", "dependencies": { - "hashery": "^1.3.0", + "hashery": "^1.5.1", "keyv": "^5.6.0" } }, @@ -257,28 +228,28 @@ } }, "node_modules/@connectrpc/connect": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.1.tgz", - "integrity": "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.1.2.tgz", + "integrity": "sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==", "license": "Apache-2.0", "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "node_modules/@connectrpc/connect-web": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.1.tgz", - "integrity": "sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.1.2.tgz", + "integrity": "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg==", "license": "Apache-2.0", "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", - "@connectrpc/connect": "2.1.1" + "@connectrpc/connect": "2.1.2" } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -323,9 +294,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.27", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", - "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", "dev": true, "funding": [ { @@ -337,7 +308,15 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT-0" + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } }, "node_modules/@csstools/css-tokenizer": { "version": "4.0.0", @@ -429,10 +408,41 @@ "postcss-selector-parser": "^7.1.1" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -441,14 +451,15 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -457,14 +468,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -473,14 +485,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -489,14 +502,15 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -505,14 +519,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -521,14 +536,15 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -537,14 +553,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -553,14 +570,15 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -569,14 +587,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -585,14 +604,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -601,14 +621,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -617,14 +638,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -633,14 +655,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -649,14 +672,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -665,14 +689,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -681,14 +706,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -697,14 +723,15 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -713,14 +740,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -729,14 +757,15 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -745,14 +774,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -761,14 +791,15 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -777,14 +808,15 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -793,14 +825,15 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -809,14 +842,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -825,14 +859,15 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -841,6 +876,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -905,15 +941,15 @@ } }, "node_modules/@hugeicons/core-free-icons": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.1.1.tgz", - "integrity": "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.1.tgz", + "integrity": "sha512-75jYZKYyA9VwS35YRmmGUGzFedbY+Fl0Vxx5FzXR2CGDlIhNRumFeVqaaKoClf2MeYEJwPAVMEL9RwCYtOgnSw==", "license": "MIT" }, "node_modules/@hugeicons/vue": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.4.tgz", - "integrity": "sha512-OtFEXbyW5jYUig98C/n/HygktLvfF5Ga6nN6gK8R0E0jCrVw3EfgoZZVXqo+xGxyIjH5R1wdbg6nJrtf6mzLKQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.6.tgz", + "integrity": "sha512-T1Wbuk2qDdoE2xcY3CDtwMOKewwIYDE7JoMVLeuAV4cbOx5CFtToHCQAmLgVPIFGGZUsV2nN52lBkts9upvePw==", "license": "MIT", "peerDependencies": { "vue": "^2.6.0 || ^3.0.0" @@ -961,44 +997,61 @@ "license": "MIT" }, "node_modules/@intlify/core-base": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz", - "integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==", + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz", + "integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==", "license": "MIT", "dependencies": { - "@intlify/message-compiler": "11.2.8", - "@intlify/shared": "11.2.8" + "@intlify/devtools-types": "11.4.6", + "@intlify/message-compiler": "11.4.6", + "@intlify/shared": "11.4.6" }, "engines": { - "node": ">= 16" + "node": ">= 22" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/devtools-types": { + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz", + "integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "11.4.6", + "@intlify/shared": "11.4.6" + }, + "engines": { + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/message-compiler": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz", - "integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==", + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz", + "integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==", "license": "MIT", "dependencies": { - "@intlify/shared": "11.2.8", + "@intlify/shared": "11.4.6", "source-map-js": "^1.0.2" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" } }, "node_modules/@intlify/shared": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz", - "integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==", + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz", + "integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==", "license": "MIT", "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" @@ -1056,6 +1109,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1088,29 +1159,19 @@ "node": ">= 8" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.2", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", - "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", - "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", - "cpu": [ - "arm" - ], + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", - "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1118,12 +1179,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", - "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1131,12 +1195,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", - "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1144,25 +1211,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", - "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", - "cpu": [ - "arm64" ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", - "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1170,12 +1227,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", - "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1183,25 +1243,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", - "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", - "cpu": [ - "arm" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", - "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1209,12 +1259,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", - "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1222,25 +1275,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", - "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", - "cpu": [ - "loong64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", - "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1248,38 +1291,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", - "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", - "cpu": [ - "riscv64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", - "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", - "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1287,12 +1307,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", - "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1300,12 +1323,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", - "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1313,12 +1339,49 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", - "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1326,25 +1389,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", - "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", - "cpu": [ - "ia32" ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", - "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1352,7 +1405,16 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -1373,10 +1435,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "license": "MIT" }, "node_modules/@types/json5": { @@ -1392,18 +1464,18 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-vue": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", - "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", + "integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==", "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.2" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, @@ -1435,53 +1507,53 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", - "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.28", + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", - "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", - "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.28", - "@vue/compiler-dom": "3.5.28", - "@vue/compiler-ssr": "3.5.28", - "@vue/shared": "3.5.28", + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.6", + "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", - "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/devtools-api": { @@ -1491,77 +1563,71 @@ "license": "MIT" }, "node_modules/@vue/devtools-kit": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.5.tgz", - "integrity": "sha512-q2VV6x1U3KJMTQPUlRMyWEKVbcHuxhqJdSr6Jtjz5uAThAIrfJ6WVZdGZm5cuO63ZnSUz0RCsVwiUUb0mDV0Yg==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz", + "integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==", "license": "MIT", "dependencies": { - "@vue/devtools-shared": "^8.0.5", + "@vue/devtools-shared": "^8.1.2", "birpc": "^2.6.1", "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^2.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" + "perfect-debounce": "^2.0.0" } }, "node_modules/@vue/devtools-shared": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.5.tgz", - "integrity": "sha512-bRLn6/spxpmgLk+iwOrR29KrYnJjG9DGpHGkDFG82UM21ZpJ39ztUT9OXX3g+usW7/b2z+h46I9ZiYyB07XMXg==", - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz", + "integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==", + "license": "MIT" }, "node_modules/@vue/reactivity": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", - "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.28" + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", - "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", - "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.28", - "@vue/runtime-core": "3.5.28", - "@vue/shared": "3.5.28", + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", - "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { - "vue": "3.5.28" + "vue": "3.5.38" } }, "node_modules/@vue/shared": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", - "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", "license": "MIT" }, "node_modules/@xterm/addon-fit": { @@ -1570,6 +1636,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -1580,9 +1652,9 @@ ] }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1601,9 +1673,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -1813,13 +1885,14 @@ } }, "node_modules/ast-walker-scope": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", - "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.4", - "ast-kit": "^2.1.3" + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" }, "engines": { "node": ">=20.19.0" @@ -1877,9 +1950,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1921,17 +1994,17 @@ } }, "node_modules/cacheable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz", - "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz", + "integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==", "dev": true, "license": "MIT", "dependencies": { - "@cacheable/memory": "^2.0.7", - "@cacheable/utils": "^2.3.3", + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.1", "hookified": "^1.15.0", - "keyv": "^5.5.5", - "qified": "^0.6.0" + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, "node_modules/cacheable/node_modules/keyv": { @@ -2064,25 +2137,10 @@ "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", "license": "MIT" }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2121,24 +2179,24 @@ } }, "node_modules/css-functions-list": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12 || >=16" + "node": ">=12" } }, "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" @@ -2271,6 +2329,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2504,11 +2571,13 @@ } }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "hasInstallScript": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2516,32 +2585,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-string-regexp": { @@ -3093,6 +3162,23 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -3172,9 +3258,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/for-each": { @@ -3260,9 +3346,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -3442,9 +3528,9 @@ } }, "node_modules/globby": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-16.1.0.tgz", - "integrity": "sha512-+A4Hq7m7Ze592k9gZRy4gJ27DrXRNnC1vPjxTt1qQxEY8RxagBkBxivkCwg7FxSTG0iLLEMaUx13oOr0R2/qcQ==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz", + "integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3590,13 +3676,13 @@ } }, "node_modules/hashery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz", - "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.14.0" + "hookified": "^1.15.0" }, "engines": { "node": ">=20" @@ -3986,15 +4072,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", - "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4131,18 +4208,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -4177,9 +4242,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4276,13 +4351,6 @@ "node": ">=0.10.0" } }, - "node_modules/known-css-properties": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", - "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", - "dev": true, - "license": "MIT" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4296,6 +4364,255 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4342,9 +4659,9 @@ } }, "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", "license": "MIT", "dependencies": { "mlly": "^1.7.4", @@ -4442,16 +4759,16 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/meow": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-14.0.0.tgz", - "integrity": "sha512-JhC3R1f6dbspVtmF3vKjAWz1EVIvwFrGGPLSdU6rK79xBwHWTuHoLnRX/t1/zHS1Ch1Y2UtIrih7DAHuH9JFJA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", "dev": true, "license": "MIT", "engines": { @@ -4486,9 +4803,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4506,22 +4823,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "ufo": "^1.6.3" } }, "node_modules/mlly/node_modules/confbox": { @@ -4554,9 +4865,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -4875,25 +5186,25 @@ "license": "ISC" }, "node_modules/picocrank": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.0.tgz", - "integrity": "sha512-ksjqPHFMFE6ENaIXjhund50wocFmaLy22jYgWlWikugHBdd/0YlHfOOuoIMn0wKV8bSrJhcM3pQug/qz45Bc4g==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.17.0.tgz", + "integrity": "sha512-EIUSI26elt6ulloN74CT2sX5tBxS2wjdV4A+eXHYNtHbH0hM2iKkYzpKKWOEr+yUTfrvjah6VuqieedFw/9R0w==", "license": "ISC", "dependencies": { - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/vue": "^1.0.4", - "@vitejs/plugin-vue": "^6.0.4", + "@hugeicons/core-free-icons": "^4.1.1", + "@hugeicons/vue": "^1.0.5", + "@vitejs/plugin-vue": "^6.0.6", "femtocrank": "^2.5.0", - "unplugin-vue-components": "^31.0.0", - "vite": "^7.3.1", - "vue": "^3.5.28", - "vue-router": "^5.0.2" + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.10", + "vue": "^3.5.33", + "vue-router": "^5.0.6" } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -5007,9 +5318,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -5026,7 +5337,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5119,18 +5430,25 @@ } }, "node_modules/qified": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz", - "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.14.0" + "hookified": "^2.1.1" }, "engines": { "node": ">=20" } }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -5285,12 +5603,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "license": "MIT" - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -5307,43 +5619,37 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rollup": { - "version": "4.45.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", - "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "rollup": "dist/bin/rollup" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.45.1", - "@rollup/rollup-android-arm64": "4.45.1", - "@rollup/rollup-darwin-arm64": "4.45.1", - "@rollup/rollup-darwin-x64": "4.45.1", - "@rollup/rollup-freebsd-arm64": "4.45.1", - "@rollup/rollup-freebsd-x64": "4.45.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", - "@rollup/rollup-linux-arm-musleabihf": "4.45.1", - "@rollup/rollup-linux-arm64-gnu": "4.45.1", - "@rollup/rollup-linux-arm64-musl": "4.45.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-gnu": "4.45.1", - "@rollup/rollup-linux-riscv64-musl": "4.45.1", - "@rollup/rollup-linux-s390x-gnu": "4.45.1", - "@rollup/rollup-linux-x64-gnu": "4.45.1", - "@rollup/rollup-linux-x64-musl": "4.45.1", - "@rollup/rollup-win32-arm64-msvc": "4.45.1", - "@rollup/rollup-win32-ia32-msvc": "4.45.1", - "@rollup/rollup-win32-x64-msvc": "4.45.1", - "fsevents": "~2.3.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/run-parallel": { @@ -5626,15 +5932,6 @@ "node": ">=0.10.0" } }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/standard": { "version": "17.1.2", "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.2.tgz", @@ -5854,9 +6151,9 @@ } }, "node_modules/stylelint": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.3.0.tgz", - "integrity": "sha512-1POV91lcEMhj6SLVaOeA0KlS9yattS+qq+cyWqP/nYzWco7K5jznpGH1ExngvPlTM9QF1Kjd2bmuzJu9TH2OcA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz", + "integrity": "sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==", "dev": true, "funding": [ { @@ -5870,45 +6167,41 @@ ], "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", + "@csstools/css-calc": "^3.2.1", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-syntax-patches-for-csstree": "^1.0.26", + "@csstools/css-syntax-patches-for-csstree": "^1.1.4", "@csstools/css-tokenizer": "^4.0.0", "@csstools/media-query-list-parser": "^5.0.0", "@csstools/selector-resolve-nested": "^4.0.0", "@csstools/selector-specificity": "^6.0.0", - "balanced-match": "^3.0.1", "colord": "^2.9.3", - "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.3", - "css-tree": "^3.1.0", + "cosmiconfig": "^9.0.1", + "css-functions-list": "^3.3.3", + "css-tree": "^3.2.1", "debug": "^4.4.3", "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^11.1.2", + "file-entry-cache": "^11.1.3", "global-modules": "^2.0.0", - "globby": "^16.1.0", + "globby": "^16.2.0", "globjoin": "^0.1.4", "html-tags": "^5.1.0", "ignore": "^7.0.5", "import-meta-resolve": "^4.2.0", - "imurmurhash": "^0.1.4", - "is-plain-object": "^5.0.0", - "known-css-properties": "^0.37.0", "mathml-tag-names": "^4.0.0", - "meow": "^14.0.0", + "meow": "^14.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "picocolors": "^1.1.1", - "postcss": "^8.5.6", + "postcss": "^8.5.15", "postcss-safe-parser": "^7.0.1", "postcss-selector-parser": "^7.1.1", "postcss-value-parser": "^4.2.0", - "string-width": "^8.1.1", + "string-width": "^8.2.1", "supports-hyperlinks": "^4.4.0", "svg-tags": "^1.0.0", "table": "^6.9.0", - "write-file-atomic": "^7.0.0" + "write-file-atomic": "^7.0.1" }, "bin": { "stylelint": "bin/stylelint.mjs" @@ -5979,35 +6272,25 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/stylelint/node_modules/balanced-match": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-3.0.1.tgz", - "integrity": "sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", - "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz", + "integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.20" + "flat-cache": "^6.1.22" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.20.tgz", - "integrity": "sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==", + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^2.3.2", - "flatted": "^3.3.3", + "cacheable": "^2.3.4", + "flatted": "^3.4.2", "hookified": "^1.15.0" } }, @@ -6022,14 +6305,14 @@ } }, "node_modules/stylelint/node_modules/string-width": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", - "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -6039,13 +6322,13 @@ } }, "node_modules/stylelint/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -6054,18 +6337,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6156,15 +6427,16 @@ } }, "node_modules/table/node_modules/ajv": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.14.0.tgz", - "integrity": "sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -6184,13 +6456,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6217,9 +6489,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6253,6 +6525,13 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6352,9 +6631,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/unbox-primitive": { @@ -6389,18 +6668,17 @@ } }, "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=18.12.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/unplugin-utils": { @@ -6420,9 +6698,9 @@ } }, "node_modules/unplugin-utils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6432,19 +6710,19 @@ } }, "node_modules/unplugin-vue-components": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-31.0.0.tgz", - "integrity": "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q==", + "version": "32.1.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", + "integrity": "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==", "license": "MIT", "dependencies": { "chokidar": "^5.0.0", - "local-pkg": "^1.1.2", + "local-pkg": "^1.2.0", "magic-string": "^0.30.21", - "mlly": "^1.8.0", + "mlly": "^1.8.2", "obug": "^2.1.1", - "picomatch": "^4.0.3", - "tinyglobby": "^0.2.15", - "unplugin": "^2.3.11", + "picomatch": "^4.0.4", + "tinyglobby": "^0.2.16", + "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "engines": { @@ -6464,9 +6742,9 @@ } }, "node_modules/unplugin-vue-components/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6476,9 +6754,9 @@ } }, "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6512,17 +6790,16 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6538,9 +6815,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -6553,15 +6831,18 @@ "@types/node": { "optional": true }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, @@ -6585,27 +6866,10 @@ } } }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6615,16 +6879,16 @@ } }, "node_modules/vue": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", - "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "version": "3.5.38", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.28", - "@vue/compiler-sfc": "3.5.28", - "@vue/runtime-dom": "3.5.28", - "@vue/server-renderer": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" @@ -6636,17 +6900,18 @@ } }, "node_modules/vue-i18n": { - "version": "11.2.8", - "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz", - "integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==", + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz", + "integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==", "license": "MIT", "dependencies": { - "@intlify/core-base": "11.2.8", - "@intlify/shared": "11.2.8", + "@intlify/core-base": "11.4.6", + "@intlify/devtools-types": "11.4.6", + "@intlify/shared": "11.4.6", "@vue/devtools-api": "^6.5.0" }, "engines": { - "node": ">= 16" + "node": ">= 22" }, "funding": { "url": "https://github.com/sponsors/kazupon" @@ -6656,37 +6921,38 @@ } }, "node_modules/vue-router": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.2.tgz", - "integrity": "sha512-YFhwaE5c5JcJpNB1arpkl4/GnO32wiUWRB+OEj1T0DlDxEZoOfbltl2xEwktNU/9o1sGcGburIXSpbLpPFe/6w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz", + "integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==", "license": "MIT", "dependencies": { - "@babel/generator": "^7.28.6", + "@babel/generator": "^8.0.0-rc.4", "@vue-macros/common": "^3.1.1", - "@vue/devtools-api": "^8.0.0", - "ast-walker-scope": "^0.8.3", + "@vue/devtools-api": "^8.1.2", + "ast-walker-scope": "^0.9.0", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", - "mlly": "^1.8.0", + "mlly": "^1.8.2", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", - "tinyglobby": "^0.2.15", + "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", - "yaml": "^2.8.2" + "yaml": "^2.9.0" }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", - "@vue/compiler-sfc": "^3.5.17", + "@vue/compiler-sfc": "^3.5.34", "pinia": "^3.0.4", - "vue": "^3.5.0" + "vite": "^7.0.0 || ^8.0.0", + "vue": "^3.5.34" }, "peerDependenciesMeta": { "@pinia/colada": { @@ -6697,16 +6963,19 @@ }, "pinia": { "optional": true + }, + "vite": { + "optional": true } } }, "node_modules/vue-router/node_modules/@vue/devtools-api": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.5.tgz", - "integrity": "sha512-DgVcW8H/Nral7LgZEecYFFYXnAvGuN9C3L3DtWekAncFBedBczpNW8iHKExfaM559Zm8wQWrwtYZ9lXthEHtDw==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz", + "integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==", "license": "MIT", "dependencies": { - "@vue/devtools-kit": "^8.0.5" + "@vue/devtools-kit": "^8.1.2" } }, "node_modules/vue-router/node_modules/json5": { @@ -6722,9 +6991,9 @@ } }, "node_modules/vue-router/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6733,20 +7002,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vue-router/node_modules/unplugin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", - "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "picomatch": "^4.0.3", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -6869,13 +7124,12 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", - "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "dev": true, "license": "ISC", "dependencies": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { @@ -6892,9 +7146,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index a56e2bb..6865bb0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,11 +6,11 @@ "source": "index.html", "devDependencies": { "process": "^0.11.10", - "stylelint": "^17.3.0", + "stylelint": "^17.13.0", "stylelint-config-standard": "^40.0.0" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test resources/vue/components/*.test.mjs" }, "author": "", "parcelIgnore": [ @@ -22,20 +22,24 @@ ], "license": "AGPL-3.0-only", "dependencies": { - "@connectrpc/connect": "^2.1.1", - "@connectrpc/connect-web": "^2.1.1", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/vue": "^1.0.4", - "@vitejs/plugin-vue": "^6.0.4", + "@connectrpc/connect": "^2.1.2", + "@connectrpc/connect-web": "^2.1.2", + "@hugeicons/core-free-icons": "^4.2.1", + "@hugeicons/vue": "^1.0.6", + "@vitejs/plugin-vue": "^6.0.7", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "iconify-icon": "^3.0.2", - "picocrank": "^1.14.0", + "picocrank": "^1.17.0", "standard": "^17.1.2", - "unplugin-vue-components": "^31.0.0", - "vite": "^7.3.1", - "vue": "^3.5.28", - "vue-i18n": "^11.2.8", - "vue-router": "^5.0.2" + "unplugin-vue-components": "^32.1.0", + "vite": "^8.0.16", + "vue": "^3.5.38", + "vue-i18n": "^11.4.6", + "vue-router": "^5.1.0" + }, + "engines": { + "node": ">=22.0.0" } } diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts index b28fdd9..78e8d7a 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.11.0 +// @generated by protoc-gen-es v2.12.0 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3) /* eslint-disable */ @@ -60,6 +60,56 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & { * @generated from field: string datetime_rate_limit_expires = 9; */ datetimeRateLimitExpires: string; + + /** + * @generated from field: bool exec_on_startup = 10; + */ + execOnStartup: boolean; + + /** + * @generated from field: repeated string exec_on_cron = 11; + */ + execOnCron: string[]; + + /** + * @generated from field: repeated string exec_on_file_created_in_dir = 12; + */ + execOnFileCreatedInDir: string[]; + + /** + * @generated from field: repeated string exec_on_file_changed_in_dir = 13; + */ + execOnFileChangedInDir: string[]; + + /** + * @generated from field: string exec_on_calendar_file = 14; + */ + execOnCalendarFile: string; + + /** + * @generated from field: repeated olivetin.api.v1.ActionWebhookExecHint exec_on_webhooks = 15; + */ + execOnWebhooks: ActionWebhookExecHint[]; + + /** + * @generated from field: bool justification = 16; + */ + justification: boolean; + + /** + * @generated from field: bool has_running_instance = 17; + */ + hasRunningInstance: boolean; + + /** + * @generated from field: bool has_queued_instance = 18; + */ + hasQueuedInstance: boolean; + + /** + * @generated from field: repeated olivetin.api.v1.ActionGroupMembership groups = 19; + */ + groups: ActionGroupMembership[]; }; /** @@ -68,6 +118,63 @@ export declare type Action = Message<"olivetin.api.v1.Action"> & { */ export declare const ActionSchema: GenMessage; +/** + * @generated from message olivetin.api.v1.ActionGroupMembership + */ +export declare type ActionGroupMembership = Message<"olivetin.api.v1.ActionGroupMembership"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: int32 max_concurrent = 2; + */ + maxConcurrent: number; + + /** + * @generated from field: int32 queue_size = 3; + */ + queueSize: number; +}; + +/** + * Describes the message olivetin.api.v1.ActionGroupMembership. + * Use `create(ActionGroupMembershipSchema)` to create a new message. + */ +export declare const ActionGroupMembershipSchema: GenMessage; + +/** + * @generated from message olivetin.api.v1.ActionWebhookExecHint + */ +export declare type ActionWebhookExecHint = Message<"olivetin.api.v1.ActionWebhookExecHint"> & { + /** + * @generated from field: string template = 1; + */ + template: string; + + /** + * @generated from field: string match_path = 2; + */ + matchPath: string; + + /** + * @generated from field: map match_headers = 3; + */ + matchHeaders: { [key: string]: string }; + + /** + * @generated from field: map match_query = 4; + */ + matchQuery: { [key: string]: string }; +}; + +/** + * Describes the message olivetin.api.v1.ActionWebhookExecHint. + * Use `create(ActionWebhookExecHintSchema)` to create a new message. + */ +export declare const ActionWebhookExecHintSchema: GenMessage; + /** * @generated from message olivetin.api.v1.ActionArgument */ @@ -188,7 +295,7 @@ export declare type GetDashboardResponse = Message<"olivetin.api.v1.GetDashboard /** * @generated from field: olivetin.api.v1.Dashboard dashboard = 4; */ - dashboard?: Dashboard; + dashboard?: Dashboard | undefined; }; /** @@ -210,6 +317,11 @@ export declare type EffectivePolicy = Message<"olivetin.api.v1.EffectivePolicy"> * @generated from field: bool show_log_list = 2; */ showLogList: boolean; + + /** + * @generated from field: bool show_version_number = 3; + */ + showVersionNumber: boolean; }; /** @@ -297,7 +409,7 @@ export declare type DashboardComponent = Message<"olivetin.api.v1.DashboardCompo /** * @generated from field: olivetin.api.v1.Action action = 6; */ - action?: Action; + action?: Action | undefined; /** * @generated from field: string entity_type = 7; @@ -334,6 +446,11 @@ export declare type StartActionRequest = Message<"olivetin.api.v1.StartActionReq * @generated from field: string unique_tracking_id = 3; */ uniqueTrackingId: string; + + /** + * @generated from field: string justification = 4; + */ + justification: string; }; /** @@ -392,6 +509,11 @@ export declare type StartActionAndWaitRequest = Message<"olivetin.api.v1.StartAc * @generated from field: repeated olivetin.api.v1.StartActionArgument arguments = 2; */ arguments: StartActionArgument[]; + + /** + * @generated from field: string justification = 3; + */ + justification: string; }; /** @@ -407,7 +529,7 @@ export declare type StartActionAndWaitResponse = Message<"olivetin.api.v1.StartA /** * @generated from field: olivetin.api.v1.LogEntry log_entry = 1; */ - logEntry?: LogEntry; + logEntry?: LogEntry | undefined; }; /** @@ -471,7 +593,7 @@ export declare type StartActionByGetAndWaitResponse = Message<"olivetin.api.v1.S /** * @generated from field: olivetin.api.v1.LogEntry log_entry = 1; */ - logEntry?: LogEntry; + logEntry?: LogEntry | undefined; }; /** @@ -495,6 +617,20 @@ export declare type GetLogsRequest = Message<"olivetin.api.v1.GetLogsRequest"> & * @generated from field: string date_filter = 2; */ dateFilter: string; + + /** + * Number of logs per page (optional; server default used if 0 or unset) + * + * @generated from field: int64 page_size = 3; + */ + pageSize: bigint; + + /** + * Optional filter expression (see logs UI syntax help) + * + * @generated from field: string filter = 4; + */ + filter: string; }; /** @@ -600,6 +736,21 @@ export declare type LogEntry = Message<"olivetin.api.v1.LogEntry"> & { * @generated from field: string binding_id = 20; */ bindingId: string; + + /** + * @generated from field: bool queued = 21; + */ + queued: boolean; + + /** + * @generated from field: string queued_for_group = 22; + */ + queuedForGroup: string; + + /** + * @generated from field: string justification = 23; + */ + justification: string; }; /** @@ -701,6 +852,131 @@ export declare type GetActionLogsResponse = Message<"olivetin.api.v1.GetActionLo */ export declare const GetActionLogsResponseSchema: GenMessage; +/** + * @generated from message olivetin.api.v1.GetExecutionQueueRequest + */ +export declare type GetExecutionQueueRequest = Message<"olivetin.api.v1.GetExecutionQueueRequest"> & { +}; + +/** + * Describes the message olivetin.api.v1.GetExecutionQueueRequest. + * Use `create(GetExecutionQueueRequestSchema)` to create a new message. + */ +export declare const GetExecutionQueueRequestSchema: GenMessage; + +/** + * @generated from message olivetin.api.v1.ExecutionQueueAction + */ +export declare type ExecutionQueueAction = Message<"olivetin.api.v1.ExecutionQueueAction"> & { + /** + * @generated from field: string binding_id = 1; + */ + bindingId: string; + + /** + * @generated from field: string action_title = 2; + */ + actionTitle: string; + + /** + * @generated from field: string action_icon = 3; + */ + actionIcon: string; + + /** + * @generated from field: int32 max_concurrent = 4; + */ + maxConcurrent: number; + + /** + * @generated from field: int32 active_count = 5; + */ + activeCount: number; + + /** + * @generated from field: string entity_prefix = 6; + */ + entityPrefix: string; + + /** + * @generated from field: repeated olivetin.api.v1.LogEntry entries = 7; + */ + entries: LogEntry[]; +}; + +/** + * Describes the message olivetin.api.v1.ExecutionQueueAction. + * Use `create(ExecutionQueueActionSchema)` to create a new message. + */ +export declare const ExecutionQueueActionSchema: GenMessage; + +/** + * @generated from message olivetin.api.v1.ExecutionQueueGroup + */ +export declare type ExecutionQueueGroup = Message<"olivetin.api.v1.ExecutionQueueGroup"> & { + /** + * @generated from field: string name = 1; + */ + name: string; + + /** + * @generated from field: string icon = 2; + */ + icon: string; + + /** + * @generated from field: int32 max_concurrent = 3; + */ + maxConcurrent: number; + + /** + * @generated from field: int32 active_count = 4; + */ + activeCount: number; + + /** + * @generated from field: repeated olivetin.api.v1.ExecutionQueueAction actions = 5; + */ + actions: ExecutionQueueAction[]; + + /** + * @generated from field: int32 queued_count = 6; + */ + queuedCount: number; + + /** + * @generated from field: int32 queue_size = 7; + */ + queueSize: number; +}; + +/** + * Describes the message olivetin.api.v1.ExecutionQueueGroup. + * Use `create(ExecutionQueueGroupSchema)` to create a new message. + */ +export declare const ExecutionQueueGroupSchema: GenMessage; + +/** + * @generated from message olivetin.api.v1.GetExecutionQueueResponse + */ +export declare type GetExecutionQueueResponse = Message<"olivetin.api.v1.GetExecutionQueueResponse"> & { + /** + * @generated from field: repeated olivetin.api.v1.ExecutionQueueGroup groups = 1; + */ + groups: ExecutionQueueGroup[]; + + /** + * @generated from field: int32 total_active = 2; + */ + totalActive: number; +}; + +/** + * Describes the message olivetin.api.v1.GetExecutionQueueResponse. + * Use `create(GetExecutionQueueResponseSchema)` to create a new message. + */ +export declare const GetExecutionQueueResponseSchema: GenMessage; + /** * @generated from message olivetin.api.v1.ValidateArgumentTypeRequest */ @@ -806,6 +1082,37 @@ export declare type ExecutionStatusRequest = Message<"olivetin.api.v1.ExecutionS */ export declare const ExecutionStatusRequestSchema: GenMessage; +/** + * @generated from message olivetin.api.v1.DashboardNavigationTarget + */ +export declare type DashboardNavigationTarget = Message<"olivetin.api.v1.DashboardNavigationTarget"> & { + /** + * @generated from field: string title = 1; + */ + title: string; + + /** + * @generated from field: string entity_type = 2; + */ + entityType: string; + + /** + * @generated from field: string entity_key = 3; + */ + entityKey: string; + + /** + * @generated from field: string path = 4; + */ + path: string; +}; + +/** + * Describes the message olivetin.api.v1.DashboardNavigationTarget. + * Use `create(DashboardNavigationTargetSchema)` to create a new message. + */ +export declare const DashboardNavigationTargetSchema: GenMessage; + /** * @generated from message olivetin.api.v1.ExecutionStatusResponse */ @@ -813,7 +1120,12 @@ export declare type ExecutionStatusResponse = Message<"olivetin.api.v1.Execution /** * @generated from field: olivetin.api.v1.LogEntry log_entry = 1; */ - logEntry?: LogEntry; + logEntry?: LogEntry | undefined; + + /** + * @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2; + */ + backToDashboards: DashboardNavigationTarget[]; }; /** @@ -1062,6 +1374,12 @@ export declare type EventStreamResponse = Message<"olivetin.api.v1.EventStreamRe */ value: EventOutputChunk; case: "outputChunk"; + } | { + /** + * @generated from field: olivetin.api.v1.EventHeartbeat heartbeat = 7; + */ + value: EventHeartbeat; + case: "heartbeat"; } | { case: undefined; value?: undefined }; }; @@ -1116,6 +1434,18 @@ export declare type EventConfigChanged = Message<"olivetin.api.v1.EventConfigCha */ export declare const EventConfigChangedSchema: GenMessage; +/** + * @generated from message olivetin.api.v1.EventHeartbeat + */ +export declare type EventHeartbeat = Message<"olivetin.api.v1.EventHeartbeat"> & { +}; + +/** + * Describes the message olivetin.api.v1.EventHeartbeat. + * Use `create(EventHeartbeatSchema)` to create a new message. + */ +export declare const EventHeartbeatSchema: GenMessage; + /** * @generated from message olivetin.api.v1.EventExecutionFinished */ @@ -1123,7 +1453,7 @@ export declare type EventExecutionFinished = Message<"olivetin.api.v1.EventExecu /** * @generated from field: olivetin.api.v1.LogEntry log_entry = 1; */ - logEntry?: LogEntry; + logEntry?: LogEntry | undefined; }; /** @@ -1139,7 +1469,7 @@ export declare type EventExecutionStarted = Message<"olivetin.api.v1.EventExecut /** * @generated from field: olivetin.api.v1.LogEntry log_entry = 1; */ - logEntry?: LogEntry; + logEntry?: LogEntry | undefined; }; /** @@ -1425,7 +1755,7 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & { /** * @generated from field: olivetin.api.v1.EffectivePolicy effective_policy = 18; */ - effectivePolicy?: EffectivePolicy; + effectivePolicy?: EffectivePolicy | undefined; /** * @generated from field: string banner_message = 19; @@ -1541,7 +1871,12 @@ export declare type GetActionBindingResponse = Message<"olivetin.api.v1.GetActio /** * @generated from field: olivetin.api.v1.Action action = 1; */ - action?: Action; + action?: Action | undefined; + + /** + * @generated from field: repeated olivetin.api.v1.DashboardNavigationTarget back_to_dashboards = 2; + */ + backToDashboards: DashboardNavigationTarget[]; }; /** @@ -1725,6 +2060,14 @@ export declare const OliveTinApiService: GenService<{ input: typeof GetActionLogsRequestSchema; output: typeof GetActionLogsResponseSchema; }, + /** + * @generated from rpc olivetin.api.v1.OliveTinApiService.GetExecutionQueue + */ + getExecutionQueue: { + methodKind: "unary"; + input: typeof GetExecutionQueueRequestSchema; + output: typeof GetExecutionQueueResponseSchema; + }, /** * @generated from rpc olivetin.api.v1.OliveTinApiService.ValidateArgumentType */ diff --git a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js index 02156ca..0ca3819 100644 --- a/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js +++ b/frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.11.0 +// @generated by protoc-gen-es v2.12.0 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3) /* eslint-disable */ @@ -8,7 +8,7 @@ import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2 * Describes the file olivetin/api/v1/olivetin.proto. */ export const file_olivetin_api_v1_olivetin = /*@__PURE__*/ - fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSLcAQoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkiuwIKDkFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEdHlwZRgDIAEoCRIVCg1kZWZhdWx0X3ZhbHVlGAQgASgJEjYKB2Nob2ljZXMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnRDaG9pY2USEwoLZGVzY3JpcHRpb24YBiABKAkSRQoLc3VnZ2VzdGlvbnMYByADKAsyMC5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQuU3VnZ2VzdGlvbnNFbnRyeRIfChdzdWdnZXN0aW9uc19icm93c2VyX2tleRgIIAEoCRoyChBTdWdnZXN0aW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiNAoUQWN0aW9uQXJndW1lbnRDaG9pY2USDQoFdmFsdWUYASABKAkSDQoFdGl0bGUYAiABKAkisgEKBkVudGl0eRINCgV0aXRsZRgBIAEoCRISCgp1bmlxdWVfa2V5GAIgASgJEgwKBHR5cGUYAyABKAkSEwoLZGlyZWN0b3JpZXMYBCADKAkSMwoGZmllbGRzGAUgAygLMiMub2xpdmV0aW4uYXBpLnYxLkVudGl0eS5GaWVsZHNFbnRyeRotCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlQKFEdldERhc2hib2FyZFJlc3BvbnNlEg0KBXRpdGxlGAEgASgJEi0KCWRhc2hib2FyZBgEIAEoCzIaLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmQiQgoPRWZmZWN0aXZlUG9saWN5EhgKEHNob3dfZGlhZ25vc3RpY3MYASABKAgSFQoNc2hvd19sb2dfbGlzdBgCIAEoCCJNChNHZXREYXNoYm9hcmRSZXF1ZXN0Eg0KBXRpdGxlGAEgASgJEhMKC2VudGl0eV90eXBlGAIgASgJEhIKCmVudGl0eV9rZXkYAyABKAkiUQoJRGFzaGJvYXJkEg0KBXRpdGxlGAEgASgJEjUKCGNvbnRlbnRzGAIgAygLMiMub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZENvbXBvbmVudCLbAQoSRGFzaGJvYXJkQ29tcG9uZW50Eg0KBXRpdGxlGAEgASgJEgwKBHR5cGUYAiABKAkSNQoIY29udGVudHMYAyADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50EgwKBGljb24YBCABKAkSEQoJY3NzX2NsYXNzGAUgASgJEicKBmFjdGlvbhgGIAEoCzIXLm9saXZldGluLmFwaS52MS5BY3Rpb24SEwoLZW50aXR5X3R5cGUYByABKAkSEgoKZW50aXR5X2tleRgIIAEoCSJ9ChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkiMgoTU3RhcnRBY3Rpb25Bcmd1bWVudBIMCgRuYW1lGAEgASgJEg0KBXZhbHVlGAIgASgJIjQKE1N0YXJ0QWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAIgASgJImcKGVN0YXJ0QWN0aW9uQW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJEjcKCWFyZ3VtZW50cxgCIAMoCzIkLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFyZ3VtZW50IkoKGlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSIsChdTdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkiOQoYU3RhcnRBY3Rpb25CeUdldFJlc3BvbnNlEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgCIAEoCSIzCh5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIk8KH1N0YXJ0QWN0aW9uQnlHZXRBbmRXYWl0UmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IjsKDkdldExvZ3NSZXF1ZXN0EhQKDHN0YXJ0X29mZnNldBgBIAEoAxITCgtkYXRlX2ZpbHRlchgCIAEoCSKaAwoITG9nRW50cnkSGAoQZGF0ZXRpbWVfc3RhcnRlZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSDgoGb3V0cHV0GAMgASgJEhEKCXRpbWVkX291dBgFIAEoCBIRCglleGl0X2NvZGUYBiABKAUSDAoEdXNlchgHIAEoCRISCgp1c2VyX2NsYXNzGAggASgJEhMKC2FjdGlvbl9pY29uGAkgASgJEgwKBHRhZ3MYCiADKAkSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAsgASgJEhkKEWRhdGV0aW1lX2ZpbmlzaGVkGAwgASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIEiMKG2RhdGV0aW1lX3JhdGVfbGltaXRfZXhwaXJlcxgTIAEoCRISCgpiaW5kaW5nX2lkGBQgASgJIpEBCg9HZXRMb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyI/ChRHZXRBY3Rpb25Mb2dzUmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSFAoMc3RhcnRfb2Zmc2V0GAIgASgDIpcBChVHZXRBY3Rpb25Mb2dzUmVzcG9uc2USJwoEbG9ncxgBIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeRIXCg9jb3VudF9yZW1haW5pbmcYAiABKAMSEQoJcGFnZV9zaXplGAMgASgDEhMKC3RvdGFsX2NvdW50GAQgASgDEhQKDHN0YXJ0X29mZnNldBgFIAEoAyJlChtWYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QSDQoFdmFsdWUYASABKAkSDAoEdHlwZRgCIAEoCRISCgpiaW5kaW5nX2lkGAMgASgJEhUKDWFyZ3VtZW50X25hbWUYBCABKAkiQgocVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZRINCgV2YWxpZBgBIAEoCBITCgtkZXNjcmlwdGlvbhgCIAEoCSI2ChVXYXRjaEV4ZWN1dGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIiYKFFdhdGNoRXhlY3V0aW9uVXBkYXRlEg4KBnVwZGF0ZRgBIAEoCSJKChZFeGVjdXRpb25TdGF0dXNSZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIRCglhY3Rpb25faWQYAiABKAkiRwoXRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2USLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5Ig8KDVdob0FtSVJlcXVlc3QibAoOV2hvQW1JUmVzcG9uc2USGgoSYXV0aGVudGljYXRlZF91c2VyGAEgASgJEhEKCXVzZXJncm91cBgCIAEoCRIQCghwcm92aWRlchgDIAEoCRIMCgRhY2xzGAQgAygJEgsKA3NpZBgFIAEoCSISChBTb3NSZXBvcnRSZXF1ZXN0IiIKEVNvc1JlcG9ydFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJIhEKD0R1bXBWYXJzUmVxdWVzdCKVAQoQRHVtcFZhcnNSZXNwb25zZRINCgVhbGVydBgBIAEoCRJBCghjb250ZW50cxgCIAMoCzIvLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1Jlc3BvbnNlLkNvbnRlbnRzRW50cnkaLwoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIjsKDERlYnVnQmluZGluZxIUCgxhY3Rpb25fdGl0bGUYASABKAkSFQoNZW50aXR5X3ByZWZpeBgCIAEoCSIeChxEdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Is4BCh1EdW1wUHVibGljSWRBY3Rpb25NYXBSZXNwb25zZRINCgVhbGVydBgBIAEoCRJOCghjb250ZW50cxgCIAMoCzI8Lm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXNwb25zZS5Db250ZW50c0VudHJ5Gk4KDUNvbnRlbnRzRW50cnkSCwoDa2V5GAEgASgJEiwKBXZhbHVlGAIgASgLMh0ub2xpdmV0aW4uYXBpLnYxLkRlYnVnQmluZGluZzoCOAEiEgoQR2V0UmVhZHl6UmVxdWVzdCIjChFHZXRSZWFkeXpSZXNwb25zZRIOCgZzdGF0dXMYASABKAkiFAoSRXZlbnRTdHJlYW1SZXF1ZXN0IuMCChNFdmVudFN0cmVhbVJlc3BvbnNlEj0KDmVudGl0eV9jaGFuZ2VkGAIgASgLMiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50RW50aXR5Q2hhbmdlZEgAEj0KDmNvbmZpZ19jaGFuZ2VkGAMgASgLMiMub2xpdmV0aW4uYXBpLnYxLkV2ZW50Q29uZmlnQ2hhbmdlZEgAEkUKEmV4ZWN1dGlvbl9maW5pc2hlZBgEIAEoCzInLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvbkZpbmlzaGVkSAASQwoRZXhlY3V0aW9uX3N0YXJ0ZWQYBSABKAsyJi5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFeGVjdXRpb25TdGFydGVkSAASOQoMb3V0cHV0X2NodW5rGAYgASgLMiEub2xpdmV0aW4uYXBpLnYxLkV2ZW50T3V0cHV0Q2h1bmtIAEIHCgVldmVudCJBChBFdmVudE91dHB1dENodW5rEh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCRIOCgZvdXRwdXQYAiABKAkiFAoSRXZlbnRFbnRpdHlDaGFuZ2VkIhQKEkV2ZW50Q29uZmlnQ2hhbmdlZCJGChZFdmVudEV4ZWN1dGlvbkZpbmlzaGVkEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSJFChVFdmVudEV4ZWN1dGlvblN0YXJ0ZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IjIKEUtpbGxBY3Rpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSJtChJLaWxsQWN0aW9uUmVzcG9uc2USHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBmtpbGxlZBgCIAEoCBIZChFhbHJlYWR5X2NvbXBsZXRlZBgDIAEoCBINCgVmb3VuZBgEIAEoCCI7ChVMb2NhbFVzZXJMb2dpblJlcXVlc3QSEAoIdXNlcm5hbWUYASABKAkSEAoIcGFzc3dvcmQYAiABKAkiKQoWTG9jYWxVc2VyTG9naW5SZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIIicKE1Bhc3N3b3JkSGFzaFJlcXVlc3QSEAoIcGFzc3dvcmQYASABKAkiJAoUUGFzc3dvcmRIYXNoUmVzcG9uc2USDAoEaGFzaBgBIAEoCSIPCg1Mb2dvdXRSZXF1ZXN0IhAKDkxvZ291dFJlc3BvbnNlIhcKFUdldERpYWdub3N0aWNzUmVxdWVzdCJFChZHZXREaWFnbm9zdGljc1Jlc3BvbnNlEhMKC1NzaEZvdW5kS2V5GAEgASgJEhYKDlNzaEZvdW5kQ29uZmlnGAIgASgJIg0KC0luaXRSZXF1ZXN0IusFCgxJbml0UmVzcG9uc2USEgoKc2hvd0Zvb3RlchgBIAEoCBIWCg5zaG93TmF2aWdhdGlvbhgCIAEoCBIXCg9zaG93TmV3VmVyc2lvbnMYAyABKAgSGAoQYXZhaWxhYmxlVmVyc2lvbhgEIAEoCRIWCg5jdXJyZW50VmVyc2lvbhgFIAEoCRIRCglwYWdlVGl0bGUYBiABKAkSHgoWc2VjdGlvbk5hdmlnYXRpb25TdHlsZRgHIAEoCRIaChJkZWZhdWx0SWNvbkZvckJhY2sYCCABKAkSFgoOZW5hYmxlQ3VzdG9tSnMYCSABKAgSFAoMYXV0aExvZ2luVXJsGAogASgJEhYKDmF1dGhMb2NhbExvZ2luGAsgASgIEhEKCXN0eWxlTW9kcxgMIAMoCRI4Cg9vQXV0aDJQcm92aWRlcnMYDSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuT0F1dGgyUHJvdmlkZXISOAoPYWRkaXRpb25hbExpbmtzGA4gAygLMh8ub2xpdmV0aW4uYXBpLnYxLkFkZGl0aW9uYWxMaW5rEhYKDnJvb3REYXNoYm9hcmRzGA8gAygJEhoKEmF1dGhlbnRpY2F0ZWRfdXNlchgQIAEoCRIjChthdXRoZW50aWNhdGVkX3VzZXJfcHJvdmlkZXIYESABKAkSOgoQZWZmZWN0aXZlX3BvbGljeRgSIAEoCzIgLm9saXZldGluLmFwaS52MS5FZmZlY3RpdmVQb2xpY3kSFgoOYmFubmVyX21lc3NhZ2UYEyABKAkSEgoKYmFubmVyX2NzcxgUIAEoCRIYChBzaG93X2RpYWdub3N0aWNzGBUgASgIEhUKDXNob3dfbG9nX2xpc3QYFiABKAgSFgoObG9naW5fcmVxdWlyZWQYFyABKAgSGAoQYXZhaWxhYmxlX3RoZW1lcxgYIAMoCRIkChxzaG93X25hdmlnYXRlX29uX3N0YXJ0X2ljb25zGBkgASgIIiwKDkFkZGl0aW9uYWxMaW5rEg0KBXRpdGxlGAEgASgJEgsKA3VybBgCIAEoCSI6Cg5PQXV0aDJQcm92aWRlchINCgV0aXRsZRgBIAEoCRIMCgRpY29uGAMgASgJEgsKA2tleRgEIAEoCSItChdHZXRBY3Rpb25CaW5kaW5nUmVxdWVzdBISCgpiaW5kaW5nX2lkGAEgASgJIkMKGEdldEFjdGlvbkJpbmRpbmdSZXNwb25zZRInCgZhY3Rpb24YASABKAsyFy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uIhQKEkdldEVudGl0aWVzUmVxdWVzdCJUChNHZXRFbnRpdGllc1Jlc3BvbnNlEj0KEmVudGl0eV9kZWZpbml0aW9ucxgBIAMoCzIhLm9saXZldGluLmFwaS52MS5FbnRpdHlEZWZpbml0aW9uImkKEEVudGl0eURlZmluaXRpb24SDQoFdGl0bGUYASABKAkSKgoJaW5zdGFuY2VzGAIgAygLMhcub2xpdmV0aW4uYXBpLnYxLkVudGl0eRIaChJ1c2VkX29uX2Rhc2hib2FyZHMYAyADKAkiNAoQR2V0RW50aXR5UmVxdWVzdBISCgp1bmlxdWVfa2V5GAEgASgJEgwKBHR5cGUYAiABKAkiNQoUUmVzdGFydEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJMugSChJPbGl2ZVRpbkFwaVNlcnZpY2USXQoMR2V0RGFzaGJvYXJkEiQub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuR2V0RGFzaGJvYXJkUmVzcG9uc2UiABJaCgtTdGFydEFjdGlvbhIjLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAEm8KElN0YXJ0QWN0aW9uQW5kV2FpdBIqLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXF1ZXN0Gisub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlIgASaQoQU3RhcnRBY3Rpb25CeUdldBIoLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBopLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVzcG9uc2UiABJ+ChdTdGFydEFjdGlvbkJ5R2V0QW5kV2FpdBIvLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QaMC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXNwb25zZSIAEl4KDVJlc3RhcnRBY3Rpb24SJS5vbGl2ZXRpbi5hcGkudjEuUmVzdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAElcKCktpbGxBY3Rpb24SIi5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlcXVlc3QaIy5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlc3BvbnNlIgASZgoPRXhlY3V0aW9uU3RhdHVzEicub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1JlcXVlc3QaKC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2UiABJOCgdHZXRMb2dzEh8ub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXF1ZXN0GiAub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXNwb25zZSIAEmAKDUdldEFjdGlvbkxvZ3MSJS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1JlcXVlc3QaJi5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1Jlc3BvbnNlIgASdQoUVmFsaWRhdGVBcmd1bWVudFR5cGUSLC5vbGl2ZXRpbi5hcGkudjEuVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Gi0ub2xpdmV0aW4uYXBpLnYxLlZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2UiABJLCgZXaG9BbUkSHi5vbGl2ZXRpbi5hcGkudjEuV2hvQW1JUmVxdWVzdBofLm9saXZldGluLmFwaS52MS5XaG9BbUlSZXNwb25zZSIAElQKCVNvc1JlcG9ydBIhLm9saXZldGluLmFwaS52MS5Tb3NSZXBvcnRSZXF1ZXN0GiIub2xpdmV0aW4uYXBpLnYxLlNvc1JlcG9ydFJlc3BvbnNlIgASUQoIRHVtcFZhcnMSIC5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXF1ZXN0GiEub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVzcG9uc2UiABJ4ChVEdW1wUHVibGljSWRBY3Rpb25NYXASLS5vbGl2ZXRpbi5hcGkudjEuRHVtcFB1YmxpY0lkQWN0aW9uTWFwUmVxdWVzdBouLm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXNwb25zZSIAElQKCUdldFJlYWR5ehIhLm9saXZldGluLmFwaS52MS5HZXRSZWFkeXpSZXF1ZXN0GiIub2xpdmV0aW4uYXBpLnYxLkdldFJlYWR5elJlc3BvbnNlIgASYwoOTG9jYWxVc2VyTG9naW4SJi5vbGl2ZXRpbi5hcGkudjEuTG9jYWxVc2VyTG9naW5SZXF1ZXN0Gicub2xpdmV0aW4uYXBpLnYxLkxvY2FsVXNlckxvZ2luUmVzcG9uc2UiABJdCgxQYXNzd29yZEhhc2gSJC5vbGl2ZXRpbi5hcGkudjEuUGFzc3dvcmRIYXNoUmVxdWVzdBolLm9saXZldGluLmFwaS52MS5QYXNzd29yZEhhc2hSZXNwb25zZSIAEksKBkxvZ291dBIeLm9saXZldGluLmFwaS52MS5Mb2dvdXRSZXF1ZXN0Gh8ub2xpdmV0aW4uYXBpLnYxLkxvZ291dFJlc3BvbnNlIgASXAoLRXZlbnRTdHJlYW0SIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRTdHJlYW1SZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLkV2ZW50U3RyZWFtUmVzcG9uc2UiADABEmMKDkdldERpYWdub3N0aWNzEiYub2xpdmV0aW4uYXBpLnYxLkdldERpYWdub3N0aWNzUmVxdWVzdBonLm9saXZldGluLmFwaS52MS5HZXREaWFnbm9zdGljc1Jlc3BvbnNlIgASRQoESW5pdBIcLm9saXZldGluLmFwaS52MS5Jbml0UmVxdWVzdBodLm9saXZldGluLmFwaS52MS5Jbml0UmVzcG9uc2UiABJpChBHZXRBY3Rpb25CaW5kaW5nEigub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkJpbmRpbmdSZXF1ZXN0Gikub2xpdmV0aW4uYXBpLnYxLkdldEFjdGlvbkJpbmRpbmdSZXNwb25zZSIAEloKC0dldEVudGl0aWVzEiMub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0aWVzUmVxdWVzdBokLm9saXZldGluLmFwaS52MS5HZXRFbnRpdGllc1Jlc3BvbnNlIgASSQoJR2V0RW50aXR5EiEub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0eVJlcXVlc3QaFy5vbGl2ZXRpbi5hcGkudjEuRW50aXR5IgBCOFo2Z2l0aHViLmNvbS9PbGl2ZVRpbi9PbGl2ZVRpbi9nZW4vb2xpdmV0aW4vYXBpL3YxO2FwaXYxYgZwcm90bzM"); + fileDesc("Ch5vbGl2ZXRpbi9hcGkvdjEvb2xpdmV0aW4ucHJvdG8SD29saXZldGluLmFwaS52MSLABAoGQWN0aW9uEhIKCmJpbmRpbmdfaWQYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEaWNvbhgDIAEoCRIQCghjYW5fZXhlYxgEIAEoCBIyCglhcmd1bWVudHMYBSADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQSFgoOcG9wdXBfb25fc3RhcnQYBiABKAkSDQoFb3JkZXIYByABKAUSDwoHdGltZW91dBgIIAEoBRIjChtkYXRldGltZV9yYXRlX2xpbWl0X2V4cGlyZXMYCSABKAkSFwoPZXhlY19vbl9zdGFydHVwGAogASgIEhQKDGV4ZWNfb25fY3JvbhgLIAMoCRIjChtleGVjX29uX2ZpbGVfY3JlYXRlZF9pbl9kaXIYDCADKAkSIwobZXhlY19vbl9maWxlX2NoYW5nZWRfaW5fZGlyGA0gAygJEh0KFWV4ZWNfb25fY2FsZW5kYXJfZmlsZRgOIAEoCRJAChBleGVjX29uX3dlYmhvb2tzGA8gAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludBIVCg1qdXN0aWZpY2F0aW9uGBAgASgIEhwKFGhhc19ydW5uaW5nX2luc3RhbmNlGBEgASgIEhsKE2hhc19xdWV1ZWRfaW5zdGFuY2UYEiABKAgSNgoGZ3JvdXBzGBMgAygLMiYub2xpdmV0aW4uYXBpLnYxLkFjdGlvbkdyb3VwTWVtYmVyc2hpcCJRChVBY3Rpb25Hcm91cE1lbWJlcnNoaXASDAoEbmFtZRgBIAEoCRIWCg5tYXhfY29uY3VycmVudBgCIAEoBRISCgpxdWV1ZV9zaXplGAMgASgFIsMCChVBY3Rpb25XZWJob29rRXhlY0hpbnQSEAoIdGVtcGxhdGUYASABKAkSEgoKbWF0Y2hfcGF0aBgCIAEoCRJPCg1tYXRjaF9oZWFkZXJzGAMgAygLMjgub2xpdmV0aW4uYXBpLnYxLkFjdGlvbldlYmhvb2tFeGVjSGludC5NYXRjaEhlYWRlcnNFbnRyeRJLCgttYXRjaF9xdWVyeRgEIAMoCzI2Lm9saXZldGluLmFwaS52MS5BY3Rpb25XZWJob29rRXhlY0hpbnQuTWF0Y2hRdWVyeUVudHJ5GjMKEU1hdGNoSGVhZGVyc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEaMQoPTWF0Y2hRdWVyeUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiuwIKDkFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdGl0bGUYAiABKAkSDAoEdHlwZRgDIAEoCRIVCg1kZWZhdWx0X3ZhbHVlGAQgASgJEjYKB2Nob2ljZXMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnRDaG9pY2USEwoLZGVzY3JpcHRpb24YBiABKAkSRQoLc3VnZ2VzdGlvbnMYByADKAsyMC5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uQXJndW1lbnQuU3VnZ2VzdGlvbnNFbnRyeRIfChdzdWdnZXN0aW9uc19icm93c2VyX2tleRgIIAEoCRoyChBTdWdnZXN0aW9uc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiNAoUQWN0aW9uQXJndW1lbnRDaG9pY2USDQoFdmFsdWUYASABKAkSDQoFdGl0bGUYAiABKAkisgEKBkVudGl0eRINCgV0aXRsZRgBIAEoCRISCgp1bmlxdWVfa2V5GAIgASgJEgwKBHR5cGUYAyABKAkSEwoLZGlyZWN0b3JpZXMYBCADKAkSMwoGZmllbGRzGAUgAygLMiMub2xpdmV0aW4uYXBpLnYxLkVudGl0eS5GaWVsZHNFbnRyeRotCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIlQKFEdldERhc2hib2FyZFJlc3BvbnNlEg0KBXRpdGxlGAEgASgJEi0KCWRhc2hib2FyZBgEIAEoCzIaLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmQiXwoPRWZmZWN0aXZlUG9saWN5EhgKEHNob3dfZGlhZ25vc3RpY3MYASABKAgSFQoNc2hvd19sb2dfbGlzdBgCIAEoCBIbChNzaG93X3ZlcnNpb25fbnVtYmVyGAMgASgIIk0KE0dldERhc2hib2FyZFJlcXVlc3QSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCSJRCglEYXNoYm9hcmQSDQoFdGl0bGUYASABKAkSNQoIY29udGVudHMYAiADKAsyIy5vbGl2ZXRpbi5hcGkudjEuRGFzaGJvYXJkQ29tcG9uZW50ItsBChJEYXNoYm9hcmRDb21wb25lbnQSDQoFdGl0bGUYASABKAkSDAoEdHlwZRgCIAEoCRI1Cghjb250ZW50cxgDIAMoCzIjLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmRDb21wb25lbnQSDAoEaWNvbhgEIAEoCRIRCgljc3NfY2xhc3MYBSABKAkSJwoGYWN0aW9uGAYgASgLMhcub2xpdmV0aW4uYXBpLnYxLkFjdGlvbhITCgtlbnRpdHlfdHlwZRgHIAEoCRISCgplbnRpdHlfa2V5GAggASgJIpQBChJTdGFydEFjdGlvblJlcXVlc3QSEgoKYmluZGluZ19pZBgBIAEoCRI3Cglhcmd1bWVudHMYAiADKAsyJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25Bcmd1bWVudBIaChJ1bmlxdWVfdHJhY2tpbmdfaWQYAyABKAkSFQoNanVzdGlmaWNhdGlvbhgEIAEoCSIyChNTdGFydEFjdGlvbkFyZ3VtZW50EgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAkiNAoTU3RhcnRBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkifgoZU3RhcnRBY3Rpb25BbmRXYWl0UmVxdWVzdBIRCglhY3Rpb25faWQYASABKAkSNwoJYXJndW1lbnRzGAIgAygLMiQub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQXJndW1lbnQSFQoNanVzdGlmaWNhdGlvbhgDIAEoCSJKChpTdGFydEFjdGlvbkFuZFdhaXRSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiLAoXU3RhcnRBY3Rpb25CeUdldFJlcXVlc3QSEQoJYWN0aW9uX2lkGAEgASgJIjkKGFN0YXJ0QWN0aW9uQnlHZXRSZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYAiABKAkiMwoeU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCSJPCh9TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlc3BvbnNlEiwKCWxvZ19lbnRyeRgBIAEoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSJeCg5HZXRMb2dzUmVxdWVzdBIUCgxzdGFydF9vZmZzZXQYASABKAMSEwoLZGF0ZV9maWx0ZXIYAiABKAkSEQoJcGFnZV9zaXplGAMgASgDEg4KBmZpbHRlchgEIAEoCSLbAwoITG9nRW50cnkSGAoQZGF0ZXRpbWVfc3RhcnRlZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSDgoGb3V0cHV0GAMgASgJEhEKCXRpbWVkX291dBgFIAEoCBIRCglleGl0X2NvZGUYBiABKAUSDAoEdXNlchgHIAEoCRISCgp1c2VyX2NsYXNzGAggASgJEhMKC2FjdGlvbl9pY29uGAkgASgJEgwKBHRhZ3MYCiADKAkSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAsgASgJEhkKEWRhdGV0aW1lX2ZpbmlzaGVkGAwgASgJEhkKEWV4ZWN1dGlvbl9zdGFydGVkGA4gASgIEhoKEmV4ZWN1dGlvbl9maW5pc2hlZBgPIAEoCBIPCgdibG9ja2VkGBAgASgIEhYKDmRhdGV0aW1lX2luZGV4GBEgASgDEhAKCGNhbl9raWxsGBIgASgIEiMKG2RhdGV0aW1lX3JhdGVfbGltaXRfZXhwaXJlcxgTIAEoCRISCgpiaW5kaW5nX2lkGBQgASgJEg4KBnF1ZXVlZBgVIAEoCBIYChBxdWV1ZWRfZm9yX2dyb3VwGBYgASgJEhUKDWp1c3RpZmljYXRpb24YFyABKAkikQEKD0dldExvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIj8KFEdldEFjdGlvbkxvZ3NSZXF1ZXN0EhEKCWFjdGlvbl9pZBgBIAEoCRIUCgxzdGFydF9vZmZzZXQYAiABKAMilwEKFUdldEFjdGlvbkxvZ3NSZXNwb25zZRInCgRsb2dzGAEgAygLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5EhcKD2NvdW50X3JlbWFpbmluZxgCIAEoAxIRCglwYWdlX3NpemUYAyABKAMSEwoLdG90YWxfY291bnQYBCABKAMSFAoMc3RhcnRfb2Zmc2V0GAUgASgDIhoKGEdldEV4ZWN1dGlvblF1ZXVlUmVxdWVzdCLGAQoURXhlY3V0aW9uUXVldWVBY3Rpb24SEgoKYmluZGluZ19pZBgBIAEoCRIUCgxhY3Rpb25fdGl0bGUYAiABKAkSEwoLYWN0aW9uX2ljb24YAyABKAkSFgoObWF4X2NvbmN1cnJlbnQYBCABKAUSFAoMYWN0aXZlX2NvdW50GAUgASgFEhUKDWVudGl0eV9wcmVmaXgYBiABKAkSKgoHZW50cmllcxgHIAMoCzIZLm9saXZldGluLmFwaS52MS5Mb2dFbnRyeSLBAQoTRXhlY3V0aW9uUXVldWVHcm91cBIMCgRuYW1lGAEgASgJEgwKBGljb24YAiABKAkSFgoObWF4X2NvbmN1cnJlbnQYAyABKAUSFAoMYWN0aXZlX2NvdW50GAQgASgFEjYKB2FjdGlvbnMYBSADKAsyJS5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVBY3Rpb24SFAoMcXVldWVkX2NvdW50GAYgASgFEhIKCnF1ZXVlX3NpemUYByABKAUiZwoZR2V0RXhlY3V0aW9uUXVldWVSZXNwb25zZRI0CgZncm91cHMYASADKAsyJC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uUXVldWVHcm91cBIUCgx0b3RhbF9hY3RpdmUYAiABKAUiZQobVmFsaWRhdGVBcmd1bWVudFR5cGVSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEgwKBHR5cGUYAiABKAkSEgoKYmluZGluZ19pZBgDIAEoCRIVCg1hcmd1bWVudF9uYW1lGAQgASgJIkIKHFZhbGlkYXRlQXJndW1lbnRUeXBlUmVzcG9uc2USDQoFdmFsaWQYASABKAgSEwoLZGVzY3JpcHRpb24YAiABKAkiNgoVV2F0Y2hFeGVjdXRpb25SZXF1ZXN0Eh0KFWV4ZWN1dGlvbl90cmFja2luZ19pZBgBIAEoCSImChRXYXRjaEV4ZWN1dGlvblVwZGF0ZRIOCgZ1cGRhdGUYASABKAkiSgoWRXhlY3V0aW9uU3RhdHVzUmVxdWVzdBIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSEQoJYWN0aW9uX2lkGAIgASgJImEKGURhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQSDQoFdGl0bGUYASABKAkSEwoLZW50aXR5X3R5cGUYAiABKAkSEgoKZW50aXR5X2tleRgDIAEoCRIMCgRwYXRoGAQgASgJIo8BChdFeGVjdXRpb25TdGF0dXNSZXNwb25zZRIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkSRgoSYmFja190b19kYXNoYm9hcmRzGAIgAygLMioub2xpdmV0aW4uYXBpLnYxLkRhc2hib2FyZE5hdmlnYXRpb25UYXJnZXQiDwoNV2hvQW1JUmVxdWVzdCJsCg5XaG9BbUlSZXNwb25zZRIaChJhdXRoZW50aWNhdGVkX3VzZXIYASABKAkSEQoJdXNlcmdyb3VwGAIgASgJEhAKCHByb3ZpZGVyGAMgASgJEgwKBGFjbHMYBCADKAkSCwoDc2lkGAUgASgJIhIKEFNvc1JlcG9ydFJlcXVlc3QiIgoRU29zUmVwb3J0UmVzcG9uc2USDQoFYWxlcnQYASABKAkiEQoPRHVtcFZhcnNSZXF1ZXN0IpUBChBEdW1wVmFyc1Jlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEkEKCGNvbnRlbnRzGAIgAygLMi8ub2xpdmV0aW4uYXBpLnYxLkR1bXBWYXJzUmVzcG9uc2UuQ29udGVudHNFbnRyeRovCg1Db250ZW50c0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiOwoMRGVidWdCaW5kaW5nEhQKDGFjdGlvbl90aXRsZRgBIAEoCRIVCg1lbnRpdHlfcHJlZml4GAIgASgJIh4KHER1bXBQdWJsaWNJZEFjdGlvbk1hcFJlcXVlc3QizgEKHUR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlEg0KBWFsZXJ0GAEgASgJEk4KCGNvbnRlbnRzGAIgAygLMjwub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlLkNvbnRlbnRzRW50cnkaTgoNQ29udGVudHNFbnRyeRILCgNrZXkYASABKAkSLAoFdmFsdWUYAiABKAsyHS5vbGl2ZXRpbi5hcGkudjEuRGVidWdCaW5kaW5nOgI4ASISChBHZXRSZWFkeXpSZXF1ZXN0IiMKEUdldFJlYWR5elJlc3BvbnNlEg4KBnN0YXR1cxgBIAEoCSIUChJFdmVudFN0cmVhbVJlcXVlc3QimQMKE0V2ZW50U3RyZWFtUmVzcG9uc2USPQoOZW50aXR5X2NoYW5nZWQYAiABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRFbnRpdHlDaGFuZ2VkSAASPQoOY29uZmlnX2NoYW5nZWQYAyABKAsyIy5vbGl2ZXRpbi5hcGkudjEuRXZlbnRDb25maWdDaGFuZ2VkSAASRQoSZXhlY3V0aW9uX2ZpbmlzaGVkGAQgASgLMicub2xpdmV0aW4uYXBpLnYxLkV2ZW50RXhlY3V0aW9uRmluaXNoZWRIABJDChFleGVjdXRpb25fc3RhcnRlZBgFIAEoCzImLm9saXZldGluLmFwaS52MS5FdmVudEV4ZWN1dGlvblN0YXJ0ZWRIABI5CgxvdXRwdXRfY2h1bmsYBiABKAsyIS5vbGl2ZXRpbi5hcGkudjEuRXZlbnRPdXRwdXRDaHVua0gAEjQKCWhlYXJ0YmVhdBgHIAEoCzIfLm9saXZldGluLmFwaS52MS5FdmVudEhlYXJ0YmVhdEgAQgcKBWV2ZW50IkEKEEV2ZW50T3V0cHV0Q2h1bmsSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJEg4KBm91dHB1dBgCIAEoCSIUChJFdmVudEVudGl0eUNoYW5nZWQiFAoSRXZlbnRDb25maWdDaGFuZ2VkIhAKDkV2ZW50SGVhcnRiZWF0IkYKFkV2ZW50RXhlY3V0aW9uRmluaXNoZWQSLAoJbG9nX2VudHJ5GAEgASgLMhkub2xpdmV0aW4uYXBpLnYxLkxvZ0VudHJ5IkUKFUV2ZW50RXhlY3V0aW9uU3RhcnRlZBIsCglsb2dfZW50cnkYASABKAsyGS5vbGl2ZXRpbi5hcGkudjEuTG9nRW50cnkiMgoRS2lsbEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJIm0KEktpbGxBY3Rpb25SZXNwb25zZRIdChVleGVjdXRpb25fdHJhY2tpbmdfaWQYASABKAkSDgoGa2lsbGVkGAIgASgIEhkKEWFscmVhZHlfY29tcGxldGVkGAMgASgIEg0KBWZvdW5kGAQgASgIIjsKFUxvY2FsVXNlckxvZ2luUmVxdWVzdBIQCgh1c2VybmFtZRgBIAEoCRIQCghwYXNzd29yZBgCIAEoCSIpChZMb2NhbFVzZXJMb2dpblJlc3BvbnNlEg8KB3N1Y2Nlc3MYASABKAgiJwoTUGFzc3dvcmRIYXNoUmVxdWVzdBIQCghwYXNzd29yZBgBIAEoCSIkChRQYXNzd29yZEhhc2hSZXNwb25zZRIMCgRoYXNoGAEgASgJIg8KDUxvZ291dFJlcXVlc3QiEAoOTG9nb3V0UmVzcG9uc2UiFwoVR2V0RGlhZ25vc3RpY3NSZXF1ZXN0IkUKFkdldERpYWdub3N0aWNzUmVzcG9uc2USEwoLU3NoRm91bmRLZXkYASABKAkSFgoOU3NoRm91bmRDb25maWcYAiABKAkiDQoLSW5pdFJlcXVlc3Qi6wUKDEluaXRSZXNwb25zZRISCgpzaG93Rm9vdGVyGAEgASgIEhYKDnNob3dOYXZpZ2F0aW9uGAIgASgIEhcKD3Nob3dOZXdWZXJzaW9ucxgDIAEoCBIYChBhdmFpbGFibGVWZXJzaW9uGAQgASgJEhYKDmN1cnJlbnRWZXJzaW9uGAUgASgJEhEKCXBhZ2VUaXRsZRgGIAEoCRIeChZzZWN0aW9uTmF2aWdhdGlvblN0eWxlGAcgASgJEhoKEmRlZmF1bHRJY29uRm9yQmFjaxgIIAEoCRIWCg5lbmFibGVDdXN0b21KcxgJIAEoCBIUCgxhdXRoTG9naW5VcmwYCiABKAkSFgoOYXV0aExvY2FsTG9naW4YCyABKAgSEQoJc3R5bGVNb2RzGAwgAygJEjgKD29BdXRoMlByb3ZpZGVycxgNIAMoCzIfLm9saXZldGluLmFwaS52MS5PQXV0aDJQcm92aWRlchI4Cg9hZGRpdGlvbmFsTGlua3MYDiADKAsyHy5vbGl2ZXRpbi5hcGkudjEuQWRkaXRpb25hbExpbmsSFgoOcm9vdERhc2hib2FyZHMYDyADKAkSGgoSYXV0aGVudGljYXRlZF91c2VyGBAgASgJEiMKG2F1dGhlbnRpY2F0ZWRfdXNlcl9wcm92aWRlchgRIAEoCRI6ChBlZmZlY3RpdmVfcG9saWN5GBIgASgLMiAub2xpdmV0aW4uYXBpLnYxLkVmZmVjdGl2ZVBvbGljeRIWCg5iYW5uZXJfbWVzc2FnZRgTIAEoCRISCgpiYW5uZXJfY3NzGBQgASgJEhgKEHNob3dfZGlhZ25vc3RpY3MYFSABKAgSFQoNc2hvd19sb2dfbGlzdBgWIAEoCBIWCg5sb2dpbl9yZXF1aXJlZBgXIAEoCBIYChBhdmFpbGFibGVfdGhlbWVzGBggAygJEiQKHHNob3dfbmF2aWdhdGVfb25fc3RhcnRfaWNvbnMYGSABKAgiLAoOQWRkaXRpb25hbExpbmsSDQoFdGl0bGUYASABKAkSCwoDdXJsGAIgASgJIjoKDk9BdXRoMlByb3ZpZGVyEg0KBXRpdGxlGAEgASgJEgwKBGljb24YAyABKAkSCwoDa2V5GAQgASgJIi0KF0dldEFjdGlvbkJpbmRpbmdSZXF1ZXN0EhIKCmJpbmRpbmdfaWQYASABKAkiiwEKGEdldEFjdGlvbkJpbmRpbmdSZXNwb25zZRInCgZhY3Rpb24YASABKAsyFy5vbGl2ZXRpbi5hcGkudjEuQWN0aW9uEkYKEmJhY2tfdG9fZGFzaGJvYXJkcxgCIAMoCzIqLm9saXZldGluLmFwaS52MS5EYXNoYm9hcmROYXZpZ2F0aW9uVGFyZ2V0IhQKEkdldEVudGl0aWVzUmVxdWVzdCJUChNHZXRFbnRpdGllc1Jlc3BvbnNlEj0KEmVudGl0eV9kZWZpbml0aW9ucxgBIAMoCzIhLm9saXZldGluLmFwaS52MS5FbnRpdHlEZWZpbml0aW9uImkKEEVudGl0eURlZmluaXRpb24SDQoFdGl0bGUYASABKAkSKgoJaW5zdGFuY2VzGAIgAygLMhcub2xpdmV0aW4uYXBpLnYxLkVudGl0eRIaChJ1c2VkX29uX2Rhc2hib2FyZHMYAyADKAkiNAoQR2V0RW50aXR5UmVxdWVzdBISCgp1bmlxdWVfa2V5GAEgASgJEgwKBHR5cGUYAiABKAkiNQoUUmVzdGFydEFjdGlvblJlcXVlc3QSHQoVZXhlY3V0aW9uX3RyYWNraW5nX2lkGAEgASgJMtYTChJPbGl2ZVRpbkFwaVNlcnZpY2USXQoMR2V0RGFzaGJvYXJkEiQub2xpdmV0aW4uYXBpLnYxLkdldERhc2hib2FyZFJlcXVlc3QaJS5vbGl2ZXRpbi5hcGkudjEuR2V0RGFzaGJvYXJkUmVzcG9uc2UiABJaCgtTdGFydEFjdGlvbhIjLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAEm8KElN0YXJ0QWN0aW9uQW5kV2FpdBIqLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkFuZFdhaXRSZXF1ZXN0Gisub2xpdmV0aW4uYXBpLnYxLlN0YXJ0QWN0aW9uQW5kV2FpdFJlc3BvbnNlIgASaQoQU3RhcnRBY3Rpb25CeUdldBIoLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVxdWVzdBopLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0UmVzcG9uc2UiABJ+ChdTdGFydEFjdGlvbkJ5R2V0QW5kV2FpdBIvLm9saXZldGluLmFwaS52MS5TdGFydEFjdGlvbkJ5R2V0QW5kV2FpdFJlcXVlc3QaMC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25CeUdldEFuZFdhaXRSZXNwb25zZSIAEl4KDVJlc3RhcnRBY3Rpb24SJS5vbGl2ZXRpbi5hcGkudjEuUmVzdGFydEFjdGlvblJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuU3RhcnRBY3Rpb25SZXNwb25zZSIAElcKCktpbGxBY3Rpb24SIi5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlcXVlc3QaIy5vbGl2ZXRpbi5hcGkudjEuS2lsbEFjdGlvblJlc3BvbnNlIgASZgoPRXhlY3V0aW9uU3RhdHVzEicub2xpdmV0aW4uYXBpLnYxLkV4ZWN1dGlvblN0YXR1c1JlcXVlc3QaKC5vbGl2ZXRpbi5hcGkudjEuRXhlY3V0aW9uU3RhdHVzUmVzcG9uc2UiABJOCgdHZXRMb2dzEh8ub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXF1ZXN0GiAub2xpdmV0aW4uYXBpLnYxLkdldExvZ3NSZXNwb25zZSIAEmAKDUdldEFjdGlvbkxvZ3MSJS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1JlcXVlc3QaJi5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uTG9nc1Jlc3BvbnNlIgASbAoRR2V0RXhlY3V0aW9uUXVldWUSKS5vbGl2ZXRpbi5hcGkudjEuR2V0RXhlY3V0aW9uUXVldWVSZXF1ZXN0Gioub2xpdmV0aW4uYXBpLnYxLkdldEV4ZWN1dGlvblF1ZXVlUmVzcG9uc2UiABJ1ChRWYWxpZGF0ZUFyZ3VtZW50VHlwZRIsLm9saXZldGluLmFwaS52MS5WYWxpZGF0ZUFyZ3VtZW50VHlwZVJlcXVlc3QaLS5vbGl2ZXRpbi5hcGkudjEuVmFsaWRhdGVBcmd1bWVudFR5cGVSZXNwb25zZSIAEksKBldob0FtSRIeLm9saXZldGluLmFwaS52MS5XaG9BbUlSZXF1ZXN0Gh8ub2xpdmV0aW4uYXBpLnYxLldob0FtSVJlc3BvbnNlIgASVAoJU29zUmVwb3J0EiEub2xpdmV0aW4uYXBpLnYxLlNvc1JlcG9ydFJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuU29zUmVwb3J0UmVzcG9uc2UiABJRCghEdW1wVmFycxIgLm9saXZldGluLmFwaS52MS5EdW1wVmFyc1JlcXVlc3QaIS5vbGl2ZXRpbi5hcGkudjEuRHVtcFZhcnNSZXNwb25zZSIAEngKFUR1bXBQdWJsaWNJZEFjdGlvbk1hcBItLm9saXZldGluLmFwaS52MS5EdW1wUHVibGljSWRBY3Rpb25NYXBSZXF1ZXN0Gi4ub2xpdmV0aW4uYXBpLnYxLkR1bXBQdWJsaWNJZEFjdGlvbk1hcFJlc3BvbnNlIgASVAoJR2V0UmVhZHl6EiEub2xpdmV0aW4uYXBpLnYxLkdldFJlYWR5elJlcXVlc3QaIi5vbGl2ZXRpbi5hcGkudjEuR2V0UmVhZHl6UmVzcG9uc2UiABJjCg5Mb2NhbFVzZXJMb2dpbhImLm9saXZldGluLmFwaS52MS5Mb2NhbFVzZXJMb2dpblJlcXVlc3QaJy5vbGl2ZXRpbi5hcGkudjEuTG9jYWxVc2VyTG9naW5SZXNwb25zZSIAEl0KDFBhc3N3b3JkSGFzaBIkLm9saXZldGluLmFwaS52MS5QYXNzd29yZEhhc2hSZXF1ZXN0GiUub2xpdmV0aW4uYXBpLnYxLlBhc3N3b3JkSGFzaFJlc3BvbnNlIgASSwoGTG9nb3V0Eh4ub2xpdmV0aW4uYXBpLnYxLkxvZ291dFJlcXVlc3QaHy5vbGl2ZXRpbi5hcGkudjEuTG9nb3V0UmVzcG9uc2UiABJcCgtFdmVudFN0cmVhbRIjLm9saXZldGluLmFwaS52MS5FdmVudFN0cmVhbVJlcXVlc3QaJC5vbGl2ZXRpbi5hcGkudjEuRXZlbnRTdHJlYW1SZXNwb25zZSIAMAESYwoOR2V0RGlhZ25vc3RpY3MSJi5vbGl2ZXRpbi5hcGkudjEuR2V0RGlhZ25vc3RpY3NSZXF1ZXN0Gicub2xpdmV0aW4uYXBpLnYxLkdldERpYWdub3N0aWNzUmVzcG9uc2UiABJFCgRJbml0Ehwub2xpdmV0aW4uYXBpLnYxLkluaXRSZXF1ZXN0Gh0ub2xpdmV0aW4uYXBpLnYxLkluaXRSZXNwb25zZSIAEmkKEEdldEFjdGlvbkJpbmRpbmcSKC5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1JlcXVlc3QaKS5vbGl2ZXRpbi5hcGkudjEuR2V0QWN0aW9uQmluZGluZ1Jlc3BvbnNlIgASWgoLR2V0RW50aXRpZXMSIy5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXRpZXNSZXF1ZXN0GiQub2xpdmV0aW4uYXBpLnYxLkdldEVudGl0aWVzUmVzcG9uc2UiABJJCglHZXRFbnRpdHkSIS5vbGl2ZXRpbi5hcGkudjEuR2V0RW50aXR5UmVxdWVzdBoXLm9saXZldGluLmFwaS52MS5FbnRpdHkiAEI4WjZnaXRodWIuY29tL09saXZlVGluL09saXZlVGluL2dlbi9vbGl2ZXRpbi9hcGkvdjE7YXBpdjFiBnByb3RvMw"); /** * Describes the message olivetin.api.v1.Action. @@ -17,474 +17,530 @@ export const file_olivetin_api_v1_olivetin = /*@__PURE__*/ export const ActionSchema = /*@__PURE__*/ messageDesc(file_olivetin_api_v1_olivetin, 0); +/** + * Describes the message olivetin.api.v1.ActionGroupMembership. + * Use `create(ActionGroupMembershipSchema)` to create a new message. + */ +export const ActionGroupMembershipSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 1); + +/** + * Describes the message olivetin.api.v1.ActionWebhookExecHint. + * Use `create(ActionWebhookExecHintSchema)` to create a new message. + */ +export const ActionWebhookExecHintSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 2); + /** * Describes the message olivetin.api.v1.ActionArgument. * Use `create(ActionArgumentSchema)` to create a new message. */ export const ActionArgumentSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 1); + messageDesc(file_olivetin_api_v1_olivetin, 3); /** * Describes the message olivetin.api.v1.ActionArgumentChoice. * Use `create(ActionArgumentChoiceSchema)` to create a new message. */ export const ActionArgumentChoiceSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 2); + messageDesc(file_olivetin_api_v1_olivetin, 4); /** * Describes the message olivetin.api.v1.Entity. * Use `create(EntitySchema)` to create a new message. */ export const EntitySchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 3); + messageDesc(file_olivetin_api_v1_olivetin, 5); /** * Describes the message olivetin.api.v1.GetDashboardResponse. * Use `create(GetDashboardResponseSchema)` to create a new message. */ export const GetDashboardResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 4); + messageDesc(file_olivetin_api_v1_olivetin, 6); /** * Describes the message olivetin.api.v1.EffectivePolicy. * Use `create(EffectivePolicySchema)` to create a new message. */ export const EffectivePolicySchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 5); + messageDesc(file_olivetin_api_v1_olivetin, 7); /** * Describes the message olivetin.api.v1.GetDashboardRequest. * Use `create(GetDashboardRequestSchema)` to create a new message. */ export const GetDashboardRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 6); + messageDesc(file_olivetin_api_v1_olivetin, 8); /** * Describes the message olivetin.api.v1.Dashboard. * Use `create(DashboardSchema)` to create a new message. */ export const DashboardSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 7); + messageDesc(file_olivetin_api_v1_olivetin, 9); /** * Describes the message olivetin.api.v1.DashboardComponent. * Use `create(DashboardComponentSchema)` to create a new message. */ export const DashboardComponentSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 8); + messageDesc(file_olivetin_api_v1_olivetin, 10); /** * Describes the message olivetin.api.v1.StartActionRequest. * Use `create(StartActionRequestSchema)` to create a new message. */ export const StartActionRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 9); + messageDesc(file_olivetin_api_v1_olivetin, 11); /** * Describes the message olivetin.api.v1.StartActionArgument. * Use `create(StartActionArgumentSchema)` to create a new message. */ export const StartActionArgumentSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 10); + messageDesc(file_olivetin_api_v1_olivetin, 12); /** * Describes the message olivetin.api.v1.StartActionResponse. * Use `create(StartActionResponseSchema)` to create a new message. */ export const StartActionResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 11); + messageDesc(file_olivetin_api_v1_olivetin, 13); /** * Describes the message olivetin.api.v1.StartActionAndWaitRequest. * Use `create(StartActionAndWaitRequestSchema)` to create a new message. */ export const StartActionAndWaitRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 12); + messageDesc(file_olivetin_api_v1_olivetin, 14); /** * Describes the message olivetin.api.v1.StartActionAndWaitResponse. * Use `create(StartActionAndWaitResponseSchema)` to create a new message. */ export const StartActionAndWaitResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 13); + messageDesc(file_olivetin_api_v1_olivetin, 15); /** * Describes the message olivetin.api.v1.StartActionByGetRequest. * Use `create(StartActionByGetRequestSchema)` to create a new message. */ export const StartActionByGetRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 14); + messageDesc(file_olivetin_api_v1_olivetin, 16); /** * Describes the message olivetin.api.v1.StartActionByGetResponse. * Use `create(StartActionByGetResponseSchema)` to create a new message. */ export const StartActionByGetResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 15); + messageDesc(file_olivetin_api_v1_olivetin, 17); /** * Describes the message olivetin.api.v1.StartActionByGetAndWaitRequest. * Use `create(StartActionByGetAndWaitRequestSchema)` to create a new message. */ export const StartActionByGetAndWaitRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 16); + messageDesc(file_olivetin_api_v1_olivetin, 18); /** * Describes the message olivetin.api.v1.StartActionByGetAndWaitResponse. * Use `create(StartActionByGetAndWaitResponseSchema)` to create a new message. */ export const StartActionByGetAndWaitResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 17); + messageDesc(file_olivetin_api_v1_olivetin, 19); /** * Describes the message olivetin.api.v1.GetLogsRequest. * Use `create(GetLogsRequestSchema)` to create a new message. */ export const GetLogsRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 18); + messageDesc(file_olivetin_api_v1_olivetin, 20); /** * Describes the message olivetin.api.v1.LogEntry. * Use `create(LogEntrySchema)` to create a new message. */ export const LogEntrySchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 19); + messageDesc(file_olivetin_api_v1_olivetin, 21); /** * Describes the message olivetin.api.v1.GetLogsResponse. * Use `create(GetLogsResponseSchema)` to create a new message. */ export const GetLogsResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 20); + messageDesc(file_olivetin_api_v1_olivetin, 22); /** * Describes the message olivetin.api.v1.GetActionLogsRequest. * Use `create(GetActionLogsRequestSchema)` to create a new message. */ export const GetActionLogsRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 21); + messageDesc(file_olivetin_api_v1_olivetin, 23); /** * Describes the message olivetin.api.v1.GetActionLogsResponse. * Use `create(GetActionLogsResponseSchema)` to create a new message. */ export const GetActionLogsResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 22); + messageDesc(file_olivetin_api_v1_olivetin, 24); + +/** + * Describes the message olivetin.api.v1.GetExecutionQueueRequest. + * Use `create(GetExecutionQueueRequestSchema)` to create a new message. + */ +export const GetExecutionQueueRequestSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 25); + +/** + * Describes the message olivetin.api.v1.ExecutionQueueAction. + * Use `create(ExecutionQueueActionSchema)` to create a new message. + */ +export const ExecutionQueueActionSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 26); + +/** + * Describes the message olivetin.api.v1.ExecutionQueueGroup. + * Use `create(ExecutionQueueGroupSchema)` to create a new message. + */ +export const ExecutionQueueGroupSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 27); + +/** + * Describes the message olivetin.api.v1.GetExecutionQueueResponse. + * Use `create(GetExecutionQueueResponseSchema)` to create a new message. + */ +export const GetExecutionQueueResponseSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 28); /** * Describes the message olivetin.api.v1.ValidateArgumentTypeRequest. * Use `create(ValidateArgumentTypeRequestSchema)` to create a new message. */ export const ValidateArgumentTypeRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 23); + messageDesc(file_olivetin_api_v1_olivetin, 29); /** * Describes the message olivetin.api.v1.ValidateArgumentTypeResponse. * Use `create(ValidateArgumentTypeResponseSchema)` to create a new message. */ export const ValidateArgumentTypeResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 24); + messageDesc(file_olivetin_api_v1_olivetin, 30); /** * Describes the message olivetin.api.v1.WatchExecutionRequest. * Use `create(WatchExecutionRequestSchema)` to create a new message. */ export const WatchExecutionRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 25); + messageDesc(file_olivetin_api_v1_olivetin, 31); /** * Describes the message olivetin.api.v1.WatchExecutionUpdate. * Use `create(WatchExecutionUpdateSchema)` to create a new message. */ export const WatchExecutionUpdateSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 26); + messageDesc(file_olivetin_api_v1_olivetin, 32); /** * Describes the message olivetin.api.v1.ExecutionStatusRequest. * Use `create(ExecutionStatusRequestSchema)` to create a new message. */ export const ExecutionStatusRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 27); + messageDesc(file_olivetin_api_v1_olivetin, 33); + +/** + * Describes the message olivetin.api.v1.DashboardNavigationTarget. + * Use `create(DashboardNavigationTargetSchema)` to create a new message. + */ +export const DashboardNavigationTargetSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 34); /** * Describes the message olivetin.api.v1.ExecutionStatusResponse. * Use `create(ExecutionStatusResponseSchema)` to create a new message. */ export const ExecutionStatusResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 28); + messageDesc(file_olivetin_api_v1_olivetin, 35); /** * Describes the message olivetin.api.v1.WhoAmIRequest. * Use `create(WhoAmIRequestSchema)` to create a new message. */ export const WhoAmIRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 29); + messageDesc(file_olivetin_api_v1_olivetin, 36); /** * Describes the message olivetin.api.v1.WhoAmIResponse. * Use `create(WhoAmIResponseSchema)` to create a new message. */ export const WhoAmIResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 30); + messageDesc(file_olivetin_api_v1_olivetin, 37); /** * Describes the message olivetin.api.v1.SosReportRequest. * Use `create(SosReportRequestSchema)` to create a new message. */ export const SosReportRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 31); + messageDesc(file_olivetin_api_v1_olivetin, 38); /** * Describes the message olivetin.api.v1.SosReportResponse. * Use `create(SosReportResponseSchema)` to create a new message. */ export const SosReportResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 32); + messageDesc(file_olivetin_api_v1_olivetin, 39); /** * Describes the message olivetin.api.v1.DumpVarsRequest. * Use `create(DumpVarsRequestSchema)` to create a new message. */ export const DumpVarsRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 33); + messageDesc(file_olivetin_api_v1_olivetin, 40); /** * Describes the message olivetin.api.v1.DumpVarsResponse. * Use `create(DumpVarsResponseSchema)` to create a new message. */ export const DumpVarsResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 34); + messageDesc(file_olivetin_api_v1_olivetin, 41); /** * Describes the message olivetin.api.v1.DebugBinding. * Use `create(DebugBindingSchema)` to create a new message. */ export const DebugBindingSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 35); + messageDesc(file_olivetin_api_v1_olivetin, 42); /** * Describes the message olivetin.api.v1.DumpPublicIdActionMapRequest. * Use `create(DumpPublicIdActionMapRequestSchema)` to create a new message. */ export const DumpPublicIdActionMapRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 36); + messageDesc(file_olivetin_api_v1_olivetin, 43); /** * Describes the message olivetin.api.v1.DumpPublicIdActionMapResponse. * Use `create(DumpPublicIdActionMapResponseSchema)` to create a new message. */ export const DumpPublicIdActionMapResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 37); + messageDesc(file_olivetin_api_v1_olivetin, 44); /** * Describes the message olivetin.api.v1.GetReadyzRequest. * Use `create(GetReadyzRequestSchema)` to create a new message. */ export const GetReadyzRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 38); + messageDesc(file_olivetin_api_v1_olivetin, 45); /** * Describes the message olivetin.api.v1.GetReadyzResponse. * Use `create(GetReadyzResponseSchema)` to create a new message. */ export const GetReadyzResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 39); + messageDesc(file_olivetin_api_v1_olivetin, 46); /** * Describes the message olivetin.api.v1.EventStreamRequest. * Use `create(EventStreamRequestSchema)` to create a new message. */ export const EventStreamRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 40); + messageDesc(file_olivetin_api_v1_olivetin, 47); /** * Describes the message olivetin.api.v1.EventStreamResponse. * Use `create(EventStreamResponseSchema)` to create a new message. */ export const EventStreamResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 41); + messageDesc(file_olivetin_api_v1_olivetin, 48); /** * Describes the message olivetin.api.v1.EventOutputChunk. * Use `create(EventOutputChunkSchema)` to create a new message. */ export const EventOutputChunkSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 42); + messageDesc(file_olivetin_api_v1_olivetin, 49); /** * Describes the message olivetin.api.v1.EventEntityChanged. * Use `create(EventEntityChangedSchema)` to create a new message. */ export const EventEntityChangedSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 43); + messageDesc(file_olivetin_api_v1_olivetin, 50); /** * Describes the message olivetin.api.v1.EventConfigChanged. * Use `create(EventConfigChangedSchema)` to create a new message. */ export const EventConfigChangedSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 44); + messageDesc(file_olivetin_api_v1_olivetin, 51); + +/** + * Describes the message olivetin.api.v1.EventHeartbeat. + * Use `create(EventHeartbeatSchema)` to create a new message. + */ +export const EventHeartbeatSchema = /*@__PURE__*/ + messageDesc(file_olivetin_api_v1_olivetin, 52); /** * Describes the message olivetin.api.v1.EventExecutionFinished. * Use `create(EventExecutionFinishedSchema)` to create a new message. */ export const EventExecutionFinishedSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 45); + messageDesc(file_olivetin_api_v1_olivetin, 53); /** * Describes the message olivetin.api.v1.EventExecutionStarted. * Use `create(EventExecutionStartedSchema)` to create a new message. */ export const EventExecutionStartedSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 46); + messageDesc(file_olivetin_api_v1_olivetin, 54); /** * Describes the message olivetin.api.v1.KillActionRequest. * Use `create(KillActionRequestSchema)` to create a new message. */ export const KillActionRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 47); + messageDesc(file_olivetin_api_v1_olivetin, 55); /** * Describes the message olivetin.api.v1.KillActionResponse. * Use `create(KillActionResponseSchema)` to create a new message. */ export const KillActionResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 48); + messageDesc(file_olivetin_api_v1_olivetin, 56); /** * Describes the message olivetin.api.v1.LocalUserLoginRequest. * Use `create(LocalUserLoginRequestSchema)` to create a new message. */ export const LocalUserLoginRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 49); + messageDesc(file_olivetin_api_v1_olivetin, 57); /** * Describes the message olivetin.api.v1.LocalUserLoginResponse. * Use `create(LocalUserLoginResponseSchema)` to create a new message. */ export const LocalUserLoginResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 50); + messageDesc(file_olivetin_api_v1_olivetin, 58); /** * Describes the message olivetin.api.v1.PasswordHashRequest. * Use `create(PasswordHashRequestSchema)` to create a new message. */ export const PasswordHashRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 51); + messageDesc(file_olivetin_api_v1_olivetin, 59); /** * Describes the message olivetin.api.v1.PasswordHashResponse. * Use `create(PasswordHashResponseSchema)` to create a new message. */ export const PasswordHashResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 52); + messageDesc(file_olivetin_api_v1_olivetin, 60); /** * Describes the message olivetin.api.v1.LogoutRequest. * Use `create(LogoutRequestSchema)` to create a new message. */ export const LogoutRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 53); + messageDesc(file_olivetin_api_v1_olivetin, 61); /** * Describes the message olivetin.api.v1.LogoutResponse. * Use `create(LogoutResponseSchema)` to create a new message. */ export const LogoutResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 54); + messageDesc(file_olivetin_api_v1_olivetin, 62); /** * Describes the message olivetin.api.v1.GetDiagnosticsRequest. * Use `create(GetDiagnosticsRequestSchema)` to create a new message. */ export const GetDiagnosticsRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 55); + messageDesc(file_olivetin_api_v1_olivetin, 63); /** * Describes the message olivetin.api.v1.GetDiagnosticsResponse. * Use `create(GetDiagnosticsResponseSchema)` to create a new message. */ export const GetDiagnosticsResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 56); + messageDesc(file_olivetin_api_v1_olivetin, 64); /** * Describes the message olivetin.api.v1.InitRequest. * Use `create(InitRequestSchema)` to create a new message. */ export const InitRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 57); + messageDesc(file_olivetin_api_v1_olivetin, 65); /** * Describes the message olivetin.api.v1.InitResponse. * Use `create(InitResponseSchema)` to create a new message. */ export const InitResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 58); + messageDesc(file_olivetin_api_v1_olivetin, 66); /** * Describes the message olivetin.api.v1.AdditionalLink. * Use `create(AdditionalLinkSchema)` to create a new message. */ export const AdditionalLinkSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 59); + messageDesc(file_olivetin_api_v1_olivetin, 67); /** * Describes the message olivetin.api.v1.OAuth2Provider. * Use `create(OAuth2ProviderSchema)` to create a new message. */ export const OAuth2ProviderSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 60); + messageDesc(file_olivetin_api_v1_olivetin, 68); /** * Describes the message olivetin.api.v1.GetActionBindingRequest. * Use `create(GetActionBindingRequestSchema)` to create a new message. */ export const GetActionBindingRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 61); + messageDesc(file_olivetin_api_v1_olivetin, 69); /** * Describes the message olivetin.api.v1.GetActionBindingResponse. * Use `create(GetActionBindingResponseSchema)` to create a new message. */ export const GetActionBindingResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 62); + messageDesc(file_olivetin_api_v1_olivetin, 70); /** * Describes the message olivetin.api.v1.GetEntitiesRequest. * Use `create(GetEntitiesRequestSchema)` to create a new message. */ export const GetEntitiesRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 63); + messageDesc(file_olivetin_api_v1_olivetin, 71); /** * Describes the message olivetin.api.v1.GetEntitiesResponse. * Use `create(GetEntitiesResponseSchema)` to create a new message. */ export const GetEntitiesResponseSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 64); + messageDesc(file_olivetin_api_v1_olivetin, 72); /** * Describes the message olivetin.api.v1.EntityDefinition. * Use `create(EntityDefinitionSchema)` to create a new message. */ export const EntityDefinitionSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 65); + messageDesc(file_olivetin_api_v1_olivetin, 73); /** * Describes the message olivetin.api.v1.GetEntityRequest. * Use `create(GetEntityRequestSchema)` to create a new message. */ export const GetEntityRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 66); + messageDesc(file_olivetin_api_v1_olivetin, 74); /** * Describes the message olivetin.api.v1.RestartActionRequest. * Use `create(RestartActionRequestSchema)` to create a new message. */ export const RestartActionRequestSchema = /*@__PURE__*/ - messageDesc(file_olivetin_api_v1_olivetin, 67); + messageDesc(file_olivetin_api_v1_olivetin, 75); /** * @generated from service olivetin.api.v1.OliveTinApiService diff --git a/frontend/resources/vue/ActionButton.vue b/frontend/resources/vue/ActionButton.vue index 8f81521..d1df11a 100644 --- a/frontend/resources/vue/ActionButton.vue +++ b/frontend/resources/vue/ActionButton.vue @@ -1,5 +1,12 @@ \ No newline at end of file + diff --git a/frontend/resources/vue/views/DiagnosticsView.vue b/frontend/resources/vue/views/DiagnosticsView.vue index a818090..675ffd9 100644 --- a/frontend/resources/vue/views/DiagnosticsView.vue +++ b/frontend/resources/vue/views/DiagnosticsView.vue @@ -162,7 +162,10 @@ async function generateBrowserInfo() { userAgentData: userAgentData } - const olivetinVersion = window.initResponse?.currentVersion || t('diagnostics.unknown') + const showVersionNumber = window.initResponse?.effectivePolicy?.showVersionNumber ?? true + const olivetinVersion = showVersionNumber + ? (window.initResponse?.currentVersion || t('diagnostics.unknown')) + : '[hidden]' const currentLanguage = locale.value || t('diagnostics.unknown') let output = ''; @@ -300,4 +303,4 @@ onMounted(() => { flex-direction: column; gap: 1em; } - \ No newline at end of file + diff --git a/frontend/resources/vue/views/EntitiesView.vue b/frontend/resources/vue/views/EntitiesView.vue index a4dd745..e6b0a2c 100644 --- a/frontend/resources/vue/views/EntitiesView.vue +++ b/frontend/resources/vue/views/EntitiesView.vue @@ -1,51 +1,66 @@ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 243919a..62a3c80 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -12,6 +12,16 @@ export default defineConfig({ }), vue(), ], + build: { + rolldownOptions: { + onLog (level, log, defaultHandler) { + if (log.code === 'INVALID_ANNOTATION') { + return + } + defaultHandler(level, log) + }, + }, + }, server: { proxy: { '/api': { diff --git a/integration-tests/.mocharc.yml b/integration-tests/.mocharc.yml index 9d389e5..188cb6c 100644 --- a/integration-tests/.mocharc.yml +++ b/integration-tests/.mocharc.yml @@ -2,3 +2,5 @@ recursive: true require: - mochaSetup.mjs +ignore: + - 'tests/**/custom-webui/**' diff --git a/integration-tests/Makefile b/integration-tests/Makefile index f79e0e4..48dfed1 100644 --- a/integration-tests/Makefile +++ b/integration-tests/Makefile @@ -11,6 +11,9 @@ find-flakey-tests: echo "Running test-run infinately" sh -c "while make test-run; do :; done" +find-flakey-tests-inf: test-install + node scripts/find-flakey-tests-inf.mjs + nginx: podman-compose up -d nginx @@ -21,4 +24,4 @@ getsnapshot: rm -rf /opt/OliveTin-snapshot/* gh run download -D /opt/OliveTin-snapshot/ -.PHONY: default +.PHONY: default find-flakey-tests find-flakey-tests-inf diff --git a/integration-tests/lib/elements.js b/integration-tests/lib/elements.js index b003bab..99dcb75 100644 --- a/integration-tests/lib/elements.js +++ b/integration-tests/lib/elements.js @@ -3,6 +3,10 @@ import fs from 'fs' import { expect } from 'chai' import { Condition } from 'selenium-webdriver' +export const DEFAULT_UI_WAIT_MS = 3000 + +const executionDialogStatusBy = By.css('.execution-dialog-status') + export async function getActionButtons () { // Currently, only the active dashboard's contents are rendered, // so we don't need to scope the selector by dashboard title. @@ -10,11 +14,11 @@ export async function getActionButtons () { } export async function getExecutionDialogOutput() { - await webdriver.wait(new Condition('Dialog with long int is visible', async () => { + await webdriver.wait(new Condition('Dialog with long int is visible', async () => { const dialog = await webdriver.findElement({ id: 'execution-results-popup' }) return await dialog.isDisplayed() })); - + const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output') return ret @@ -46,20 +50,67 @@ export function takeScreenshot (webdriver, title) { }) } -export async function getRootAndWait() { - await webdriver.get(runner.baseUrl()) - await webdriver.wait(new Condition('wait for loaded-dashboard', async function() { +export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS, expectedTitle = null) { + await webdriver.wait(new Condition('wait for loaded-dashboard', async function () { const body = await webdriver.findElement(By.tagName('body')) const attr = await body.getAttribute('loaded-dashboard') console.log('loaded-dashboard: ', attr) - if (attr) { - return true - } else { + if (attr == null || attr === '') { return false } - })) + + if (expectedTitle != null) { + return attr === expectedTitle + } + + return true + }), timeoutMs) +} + +export async function waitForLogsPage(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for logs page', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/logs/') && !url.endsWith('/logs') + }), timeoutMs) +} + +export async function waitForArgumentFormPage(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for argument form page', async () => { + const url = await webdriver.getCurrentUrl() + return url.includes('/actionBinding/') && url.includes('/argumentForm') + }), timeoutMs) +} + +export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for argument form ready', async () => { + const body = await webdriver.findElement(By.tagName('body')) + const attr = await body.getAttribute('loaded-argument-form') + return attr != null && attr !== '' + }), timeoutMs) +} + +export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) { + await webdriver.wait(new Condition('wait for execution status', async () => { + const statusElements = await webdriver.findElements(executionDialogStatusBy) + return statusElements.length > 0 + }), timeoutMs) + + await webdriver.wait(new Condition('wait for execution to finish', async () => { + try { + const statusElement = await webdriver.findElement(executionDialogStatusBy) + const statusText = await statusElement.getText() + return !statusText.includes('Still running') && !statusText.includes('Queued') + } catch (e) { + return false + } + }), timeoutMs) +} + +export async function getRootAndWait() { + await webdriver.get(runner.baseUrl()) + await waitForDashboardLoaded() } export async function closeSidebar() { @@ -82,7 +133,7 @@ export async function closeSidebar() { console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '') return left === neededLeft } - }), 10000); // Wait up to 10 seconds for the sidebar to close + }), DEFAULT_UI_WAIT_MS) } export async function openSidebar() { @@ -103,7 +154,7 @@ export async function openSidebar() { console.log('Sidebar opened, left is: ', left) return true } - })); + }), DEFAULT_UI_WAIT_MS) } export async function getNavigationLinks() { @@ -114,7 +165,7 @@ export async function getNavigationLinks() { export async function requireExecutionDialogStatus (webdriver, expected) { await webdriver.wait(new Condition('wait for action to be running', async function () { - const dialogStatus = await webdriver.findElement(By.id('execution-dialog-status')) + const dialogStatus = await webdriver.findElement(executionDialogStatusBy) const actual = await dialogStatus.getText() if (actual === expected) { @@ -123,7 +174,7 @@ export async function requireExecutionDialogStatus (webdriver, expected) { console.log('Waiting for domStatus text to be: ', expected, ', it is currently: ', actual) return false } - })) + }), DEFAULT_UI_WAIT_MS) } export async function findExecutionDialog (webdriver) { diff --git a/integration-tests/package-lock.json b/integration-tests/package-lock.json index 1251464..6a81085 100644 --- a/integration-tests/package-lock.json +++ b/integration-tests/package-lock.json @@ -9,13 +9,13 @@ "version": "1.0.0", "license": "AGPL-3.0-only", "dependencies": { - "wait-on": "^9.0.3" + "wait-on": "^9.0.10" }, "devDependencies": { "chai": "^6.2.2", - "eslint": "^9.39.2", - "mocha": "^11.7.5", - "selenium-webdriver": "^4.40.0" + "eslint": "^10.5.0", + "mocha": "^11.7.6", + "selenium-webdriver": "^4.45.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -67,9 +67,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -77,105 +77,68 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hapi/address": { @@ -209,9 +172,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.3.tgz", - "integrity": "sha512-QIvUMB5VZ8HMLZF9A2oWr3AFM430QC8oGd0L35y2jHpuW6bIIca6x/xL7zUf4J7L9WJ3qjz+iJII8ncaeMbpSg==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "license": "BSD-3-Clause", "engines": { "node": ">=14.0.0" @@ -321,15 +284,22 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -341,9 +311,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -364,9 +334,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -421,14 +391,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -438,14 +408,26 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/browser-stdout": { @@ -467,16 +449,6 @@ "node": ">= 0.4" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -639,13 +611,6 @@ "node": ">= 0.8" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -818,33 +783,33 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -854,8 +819,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -863,7 +827,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -878,58 +842,61 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -955,6 +922,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -1042,16 +1010,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -1086,16 +1054,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -1191,9 +1159,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1201,13 +1169,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1216,19 +1184,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1278,9 +1233,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1314,23 +1269,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "dev": true }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1437,9 +1375,9 @@ } }, "node_modules/joi": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", - "integrity": "sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -1448,17 +1386,27 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1547,15 +1495,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" }, "node_modules/log-symbols": { "version": "4.1.0", @@ -1611,16 +1554,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.5" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -1642,9 +1588,9 @@ } }, "node_modules/mocha": { - "version": "11.7.5", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", - "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "version": "11.7.6", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.6.tgz", + "integrity": "sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==", "dev": true, "license": "MIT", "dependencies": { @@ -1679,9 +1625,9 @@ } }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1689,13 +1635,13 @@ } }, "node_modules/mocha/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1792,19 +1738,6 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "dev": true }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -1863,10 +1796,13 @@ "dev": true }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -1927,16 +1863,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -1953,9 +1879,9 @@ "dev": true }, "node_modules/selenium-webdriver": { - "version": "4.40.0", - "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.40.0.tgz", - "integrity": "sha512-dU0QbnVKdPmoNP8OtMCazRdtU2Ux6Wl4FEpG1iwUbDeajJK1dBAywBLrC1D7YFRtogHzN96AbXBgBAJaarcysw==", + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.45.0.tgz", + "integrity": "sha512-Cb2nqvJiwXVOtRTCYHX9D1FJR5+Ls7aL3Nev0t6n4CpXsQ//YGiiUmSCbvTDDeLtbV85SZ46qmLab4SIYKXWRw==", "dev": true, "funding": [ { @@ -1971,8 +1897,8 @@ "dependencies": { "@bazel/runfiles": "^6.5.0", "jszip": "^3.10.1", - "tmp": "^0.2.5", - "ws": "^8.18.3" + "tmp": "^0.2.7", + "ws": "^8.21.0" }, "engines": { "node": ">= 20.0.0" @@ -2166,9 +2092,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -2210,14 +2136,14 @@ "dev": true }, "node_modules/wait-on": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", "license": "MIT", "dependencies": { - "axios": "^1.13.2", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -2346,9 +2272,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { diff --git a/integration-tests/package.json b/integration-tests/package.json index 81f10a7..d6af80d 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -12,11 +12,11 @@ "license": "AGPL-3.0-only", "devDependencies": { "chai": "^6.2.2", - "eslint": "^9.39.2", - "mocha": "^11.7.5", - "selenium-webdriver": "^4.40.0" + "eslint": "^10.5.0", + "mocha": "^11.7.6", + "selenium-webdriver": "^4.45.0" }, "dependencies": { - "wait-on": "^9.0.3" + "wait-on": "^9.0.10" } } diff --git a/integration-tests/runner.mjs b/integration-tests/runner.mjs index 5e0aba0..c8d0925 100644 --- a/integration-tests/runner.mjs +++ b/integration-tests/runner.mjs @@ -33,6 +33,10 @@ class OliveTinTestRunner { class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner { async start (cfg) { + if (this.ot != null && this.ot.exitCode == null) { + await this.stop() + } + let stdout = "" let stderr = "" @@ -94,13 +98,41 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner { } async stop () { - if ((await this.ot.exitCode) != null) { - console.log(" OliveTin local process tried stop(), but it already exited with code", this.ot.exitCode) - } else { - await this.ot.kill() - console.log(" OliveTin local process killed") + if (this.ot == null) { + return } + if (this.ot.exitCode != null) { + console.log(' OliveTin local process tried stop(), but it already exited with code', this.ot.exitCode) + } else { + const stopTimeoutMs = 5000 + const closed = new Promise((resolve) => { + this.ot.once('close', resolve) + }) + + this.ot.kill('SIGTERM') + + const didStopGracefully = await Promise.race([ + closed.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), stopTimeoutMs)) + ]) + + if (!didStopGracefully) { + console.log(' OliveTin local process did not exit after SIGTERM, sending SIGKILL') + if (this.ot.exitCode == null) { + this.ot.kill('SIGKILL') + } + await Promise.race([ + closed, + new Promise((resolve) => setTimeout(resolve, stopTimeoutMs)) + ]) + } + + console.log(' OliveTin local process killed') + } + + this.ot = null + if (process.env.CI === 'true') { // GitHub runners seem to need a bit more time to clean up await new Promise((res) => setTimeout(res, 3000)) diff --git a/integration-tests/scripts/find-flakey-tests-inf.mjs b/integration-tests/scripts/find-flakey-tests-inf.mjs new file mode 100644 index 0000000..b50cb32 --- /dev/null +++ b/integration-tests/scripts/find-flakey-tests-inf.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import { + appendFileSync, + writeFileSync, + readFileSync, + unlinkSync, +} from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..') +const logFile = process.env.FLAKEY_LOG_FILE || join(rootDir, 'flakey-test-runs.log') +const jsonlFile = process.env.FLAKEY_JSONL_FILE || join(rootDir, 'flakey-test-runs.jsonl') + +function formatFailure (failure) { + const err = failure.err || {} + const lines = [ + `FAILURE: ${failure.fullTitle || failure.title}`, + ` file: ${failure.file || 'unknown'}`, + ` message: ${(err.message || 'unknown').trim()}`, + ] + + if (err.stack) { + lines.push(' stack:') + for (const line of err.stack.split('\n').slice(0, 8)) { + lines.push(` ${line}`) + } + } + + return lines.join('\n') +} + +function appendRunLog (run, exitCode, report, durationMs, spawnError) { + const timestamp = new Date().toISOString() + const stats = report?.stats || {} + const passes = stats.passes ?? '?' + const failures = stats.failures ?? '?' + const pending = stats.pending ?? 0 + const passed = exitCode === 0 && !spawnError + const result = passed ? 'PASS' : 'FAIL' + const durationSec = (durationMs / 1000).toFixed(1) + + const block = [ + `=== RUN ${run} | ${timestamp} | ${result} | ${passes} pass ${failures} fail ${pending} pending | ${durationSec}s ===`, + ] + + if (spawnError) { + block.push(`SPAWN_ERROR: ${spawnError}`) + } + + if (report?.failures?.length) { + for (const failure of report.failures) { + block.push(formatFailure(failure)) + } + } else if (!passed && !report) { + block.push('No JSON report captured (mocha may have crashed before writing results)') + } + + block.push('') + appendFileSync(logFile, `${block.join('\n')}\n`) + + const jsonl = { + run, + timestamp, + exitCode, + durationMs, + passes, + failures, + pending, + failureDetails: (report?.failures || []).map((failure) => ({ + fullTitle: failure.fullTitle || failure.title, + file: failure.file, + message: failure.err?.message, + stack: failure.err?.stack, + })), + } + appendFileSync(jsonlFile, `${JSON.stringify(jsonl)}\n`) +} + +function runMochaOnce () { + const reportPath = join(tmpdir(), `mocha-flakey-${randomUUID()}.json`) + + return new Promise((resolve) => { + const proc = spawn('npx', [ + 'mocha', + 'tests', + '--recursive', + '-t', + '10000', + '--reporter', + 'json', + '--reporter-option', + `output=${reportPath}`, + ], { + cwd: rootDir, + stdio: ['ignore', 'inherit', 'inherit'], + }) + + proc.on('close', (exitCode) => { + let report = null + try { + report = JSON.parse(readFileSync(reportPath, 'utf8')) + } catch { + report = null + } + + try { + unlinkSync(reportPath) + } catch { + // ignore missing temp report + } + + resolve({ exitCode: exitCode ?? 1, report }) + }) + + proc.on('error', (spawnError) => { + resolve({ exitCode: 1, report: null, spawnError: spawnError.message }) + }) + }) +} + +async function main () { + const header = [ + `# Flaky test run log started ${new Date().toISOString()}`, + `# Log file: ${logFile}`, + `# JSONL file: ${jsonlFile}`, + '', + ].join('\n') + + writeFileSync(logFile, `${header}\n`) + writeFileSync(jsonlFile, '') + + console.log(`Logging flaky test runs to ${logFile}`) + console.log(`Structured run data: ${jsonlFile}`) + + let run = 0 + + while (true) { + run += 1 + console.log(`\n--- Starting run ${run} ---`) + + const start = Date.now() + const { exitCode, report, spawnError } = await runMochaOnce() + const durationMs = Date.now() - start + + appendRunLog(run, exitCode, report, durationMs, spawnError) + + const summary = exitCode === 0 ? 'PASS' : 'FAIL' + console.log(`Run ${run}: ${summary} (${(durationMs / 1000).toFixed(1)}s) — logged`) + + if (exitCode !== 0) { + console.log(`Failure on run ${run}, stopping. See ${logFile}`) + process.exit(exitCode) + } + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/integration-tests/tests/checkbox/checkbox.mjs b/integration-tests/tests/checkbox/checkbox.mjs index 1dcdc78..f883095 100644 --- a/integration-tests/tests/checkbox/checkbox.mjs +++ b/integration-tests/tests/checkbox/checkbox.mjs @@ -2,10 +2,14 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' import { By, Condition } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, getActionButton, takeScreenshotOnFailure, getTerminalBuffer, + waitForArgumentFormPage, + waitForLogsPage, + waitForExecutionComplete, } from '../../lib/elements.js' async function openCheckboxArgumentForm() { @@ -13,13 +17,7 @@ async function openCheckboxArgumentForm() { const btn = await getActionButton(webdriver, 'Test checkbox argument') await btn.click() - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 5000 - ) + await waitForArgumentFormPage() } async function getCheckboxInput() { @@ -31,42 +29,6 @@ async function submitCheckboxForm() { await submitButton.click() } -async function waitForLogsPage() { - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), - 5000 - ) -} - -async function waitForExecutionComplete() { - await webdriver.wait( - new Condition('wait for execution status', async () => { - const statusElements = await webdriver.findElements(By.id('execution-dialog-status')) - return statusElements.length > 0 - }), - 5000 - ) - - await webdriver.wait( - new Condition('wait for execution to finish', async () => { - try { - const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) - const statusText = await statusElement.getText() - return !statusText.includes('Executing') - } catch (e) { - return false - } - }), - 5000 - ) - - // Small delay to allow terminal to write output - await webdriver.sleep(500) -} - async function waitForTerminalOutput(expectedValue) { await webdriver.wait( new Condition(`wait for checkbox value ${expectedValue} in output`, async () => { @@ -77,18 +39,18 @@ async function waitForTerminalOutput(expectedValue) { if (!terminalReady) { return false } - + const output = await getTerminalBuffer() if (!output) { return false } - + return output.trim().includes(`Checkbox value: ${expectedValue}`) } catch (e) { return false } }), - 5000 + DEFAULT_UI_WAIT_MS ) } @@ -118,7 +80,6 @@ describe('config: checkbox', function () { }) it('Checkbox argument submits 0 by default when unchecked', async function () { - this.timeout(15000) await openCheckboxArgumentForm() const checkboxInput = await getCheckboxInput() @@ -131,7 +92,6 @@ describe('config: checkbox', function () { }) it('Checkbox argument can be toggled and submitted', async function () { - this.timeout(15000) await openCheckboxArgumentForm() const checkboxInput = await getCheckboxInput() @@ -146,5 +106,3 @@ describe('config: checkbox', function () { await waitForTerminalOutput('1') }) }) - - diff --git a/integration-tests/tests/checkbox/config.yaml b/integration-tests/tests/checkbox/config.yaml index 3ca7060..2afc362 100644 --- a/integration-tests/tests/checkbox/config.yaml +++ b/integration-tests/tests/checkbox/config.yaml @@ -3,6 +3,7 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337 logLevel: "DEBUG" checkForUpdates: false +defaultPopupOnStart: execution-dialog actions: - title: Test checkbox argument diff --git a/integration-tests/tests/cssClass/cssClass.mjs b/integration-tests/tests/cssClass/cssClass.mjs index f6ee292..6d43de7 100644 --- a/integration-tests/tests/cssClass/cssClass.mjs +++ b/integration-tests/tests/cssClass/cssClass.mjs @@ -74,4 +74,15 @@ describe('config: cssClass', function () { const classAttr = await displayElements[0].getAttribute('class') expect(classAttr).to.include('test-display-class') }) + + it('custom theme applies background color to display component via cssClass', async function () { + await getRootAndWait() + + const displayElements = await webdriver.findElements(By.css('.display.test-display-class')) + expect(displayElements).to.have.length.at.least(1, 'Display with test-display-class should exist') + + const bgColor = await displayElements[0].getCssValue('background-color') + expect(bgColor, 'Theme theme.css should set .display.test-display-class background to rgb(64, 128, 192)') + .to.match(/rgba?\(\s*64\s*,\s*128\s*,\s*192\s*(,\s*1)?\s*\)/) + }) }) diff --git a/integration-tests/tests/cssClass/custom-webui/themes/cssclass-theme/theme.css b/integration-tests/tests/cssClass/custom-webui/themes/cssclass-theme/theme.css index 5373d91..3953658 100644 --- a/integration-tests/tests/cssClass/custom-webui/themes/cssclass-theme/theme.css +++ b/integration-tests/tests/cssClass/custom-webui/themes/cssclass-theme/theme.css @@ -2,3 +2,8 @@ .action-button button.test-custom-class { background-color: rgb(32, 64, 128); } + +/* Display cssClass must be overridable by theme rules (#804) */ +.display.test-display-class { + background-color: rgb(64, 128, 192); +} diff --git a/integration-tests/tests/customJs/config.yaml b/integration-tests/tests/customJs/config.yaml new file mode 100644 index 0000000..785cdd7 --- /dev/null +++ b/integration-tests/tests/customJs/config.yaml @@ -0,0 +1,16 @@ +# +# Integration Test Config: custom JavaScript (#803) +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +enableCustomJs: true + +actions: [] + +dashboards: + - title: Custom JS Dashboard + contents: [] diff --git a/integration-tests/tests/customJs/custom-webui/custom.js b/integration-tests/tests/customJs/custom-webui/custom.js new file mode 100644 index 0000000..29305d1 --- /dev/null +++ b/integration-tests/tests/customJs/custom-webui/custom.js @@ -0,0 +1 @@ +window.olivetinCustomJsLoaded = true diff --git a/integration-tests/tests/customJs/customJs.mjs b/integration-tests/tests/customJs/customJs.mjs new file mode 100644 index 0000000..60c5a7a --- /dev/null +++ b/integration-tests/tests/customJs/customJs.mjs @@ -0,0 +1,35 @@ +import { describe, it, before, after, afterEach } from 'mocha' +import { expect } from 'chai' +import { By } from 'selenium-webdriver' +import { + getRootAndWait, + takeScreenshotOnFailure, +} from '../../lib/elements.js' + +describe('config: customJs', function () { + before(async function () { + await runner.start('customJs') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('loads custom.js when enableCustomJs is true (#803)', async function () { + await getRootAndWait() + + await webdriver.wait(async () => { + return await webdriver.executeScript('return window.olivetinCustomJsLoaded === true') + }, 5000, 'custom.js should set window.olivetinCustomJsLoaded when enableCustomJs is enabled') + + const loaded = await webdriver.executeScript('return window.olivetinCustomJsLoaded === true') + expect(loaded).to.equal(true) + + const scripts = await webdriver.findElements(By.css('#olivetin-custom-js')) + expect(scripts).to.have.length(1, 'custom.js script tag should be injected into the page') + }) +}) diff --git a/integration-tests/tests/datetime/config.yaml b/integration-tests/tests/datetime/config.yaml index 8647e15..7e3c38b 100644 --- a/integration-tests/tests/datetime/config.yaml +++ b/integration-tests/tests/datetime/config.yaml @@ -3,6 +3,7 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337 logLevel: "DEBUG" checkForUpdates: false +defaultPopupOnStart: execution-dialog actions: - title: Test datetime argument diff --git a/integration-tests/tests/datetime/datetime.mjs b/integration-tests/tests/datetime/datetime.mjs index d748d7f..3aed3a2 100644 --- a/integration-tests/tests/datetime/datetime.mjs +++ b/integration-tests/tests/datetime/datetime.mjs @@ -1,10 +1,12 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, Condition } from 'selenium-webdriver' +import { By } from 'selenium-webdriver' import { getRootAndWait, getActionButton, takeScreenshotOnFailure, + waitForArgumentFormReady, + waitForLogsPage, } from '../../lib/elements.js' describe('config: datetime', function () { @@ -27,14 +29,7 @@ describe('config: datetime', function () { await btn.click() - // Wait for navigation to argument form page - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 8000 - ) + await waitForArgumentFormReady() // Find the datetime input field const datetimeInput = await webdriver.findElement(By.id('datetime')) @@ -59,14 +54,7 @@ describe('config: datetime', function () { await btn.click() - // Wait for navigation to argument form page - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 8000 - ) + await waitForArgumentFormReady() // Find the datetime input field const datetimeInput = await webdriver.findElement(By.id('datetime')) @@ -74,7 +62,7 @@ describe('config: datetime', function () { // Set a datetime value (format: YYYY-MM-DDTHH:mm) // datetime-local returns values without seconds, backend will add :00 const testDateTime = '2023-12-25T15:30' - + // Use JavaScript to set the value directly (more reliable for datetime-local inputs) await webdriver.executeScript( 'arguments[0].value = arguments[1]', @@ -101,18 +89,10 @@ describe('config: datetime', function () { ) await submitButton.click() - // Wait for navigation to logs page - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') - }), - 8000 - ) + await waitForLogsPage() // Verify we're on the logs page (action was executed) const url = await webdriver.getCurrentUrl() expect(url).to.include('/logs/') }) }) - diff --git a/integration-tests/tests/enabledExpression/enabledExpression.mjs b/integration-tests/tests/enabledExpression/enabledExpression.mjs index 98f32c0..8945940 100644 --- a/integration-tests/tests/enabledExpression/enabledExpression.mjs +++ b/integration-tests/tests/enabledExpression/enabledExpression.mjs @@ -44,7 +44,7 @@ describe('config: enabledExpression', function () { } // Accept either decoded or encoded version (component should decode, but handle both) return attr === 'LightDashboard' - }), 10000) + }), 3000) // Verify we got the correct dashboard (prefer decoded, but accept encoded) const body = await webdriver.findElement(By.tagName('body')) @@ -60,7 +60,7 @@ describe('config: enabledExpression', function () { // Debug: Check what's on the page const dashboardRows = await webdriver.findElements(By.css('.dashboard-row')) console.log(`Found ${dashboardRows.length} dashboard rows`) - + for (let i = 0; i < dashboardRows.length; i++) { const row = dashboardRows[i] const h2Elements = await row.findElements(By.css('h2')) @@ -82,25 +82,25 @@ describe('config: enabledExpression', function () { // Bedroom Light (powered_on: true) - Turn Off should be enabled, Turn On disabled let turnOnButton = null let turnOffButton = null - + for (const row of dashboardRows) { // Get the fieldset in this row const fieldsets = await row.findElements(By.css('fieldset')) if (fieldsets.length === 0) continue - + const buttons = await fieldsets[0].findElements(By.css('.action-button button')) - + // Check each button to identify which entity this row represents for (const btn of buttons) { const title = await btn.getAttribute('title') const disabled = await btn.getAttribute('disabled') const isEnabled = disabled === null - + if (title === 'Turn On Light' && isEnabled) { // This is the Living Room Light row (Turn On is enabled because powered_on: false) turnOnButton = btn } - + if (title === 'Turn Off Light' && isEnabled) { // This is the Bedroom Light row (Turn Off is enabled because powered_on: true) turnOffButton = btn @@ -127,7 +127,7 @@ describe('config: enabledExpression', function () { await webdriver.get(runner.baseUrl()) // Wait for action buttons - await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000) + await webdriver.wait(until.elementLocated(By.css('.action-button')), 3000) // Find "Always Enabled Action" button const actionButtons = await webdriver.findElements(By.css('.action-button button')) diff --git a/integration-tests/tests/entities/entities.js b/integration-tests/tests/entities/entities.js index 43ebc81..f2e2673 100644 --- a/integration-tests/tests/entities/entities.js +++ b/integration-tests/tests/entities/entities.js @@ -1,15 +1,15 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until } from 'selenium-webdriver' -import { - getRootAndWait, - takeScreenshot, +import { By } from 'selenium-webdriver' +import { + getRootAndWait, takeScreenshotOnFailure, } from '../../lib/elements.js' describe('config: entities', function () { before(async function () { await runner.start('entities') + await getRootAndWait() }) after(async () => { diff --git a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js b/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js index eb92a07..072131d 100644 --- a/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js +++ b/integration-tests/tests/entityFilesWithLongIntsUseStandardForm/entityFilesWithLongIntsUseStandardForm.js @@ -1,16 +1,19 @@ // Issue: https://github.com/OliveTin/OliveTin/issues/616 import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until, Condition } from 'selenium-webdriver' -import { - getRootAndWait, +import { By } from 'selenium-webdriver' +import { + getRootAndWait, getActionButtons, takeScreenshotOnFailure, + waitForLogsPage, + waitForExecutionComplete, } from '../../lib/elements.js' -describe('config: entities', function () { +describe('config: entityFilesWithLongIntsUseStandardForm', function () { before(async function () { await runner.start('entityFilesWithLongIntsUseStandardForm') + await getRootAndWait() }) after(async () => { @@ -29,27 +32,17 @@ describe('config: entities', function () { expect(buttons).to.not.be.null expect(buttons).to.have.length(5) - // Test INT with 10 numbers - const buttonInt10 = await buttons[2] + // Entity buttons are in numeric key order (0,1,2,3,4); first row is "INT with 10 numbers" + const buttonInt10 = await buttons[0] expect(await buttonInt10.getAttribute('title')).to.be.equal('Test me INT with 10 numbers') await buttonInt10.click() - // Wait for navigation to execution view - await webdriver.wait(new Condition('wait for execution view', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), 10000) + await waitForLogsPage() + await waitForExecutionComplete() - // Wait for execution to complete - look for the execution status - await webdriver.wait(new Condition('wait for execution status', async () => { - const statusElement = await webdriver.findElements(By.id('execution-dialog-status')) - return statusElement.length > 0 - }), 15000) - - // Check that the execution completed successfully by looking at the status - const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) + const statusElement = await webdriver.findElement(By.css('.execution-dialog-status')) const statusText = await statusElement.getText() - + // The status should indicate success (not "Executing..." or "Failed") expect(statusText).to.not.include('Executing') expect(statusText).to.not.include('Failed') diff --git a/integration-tests/tests/entityHtmlDisplay/config.yaml b/integration-tests/tests/entityHtmlDisplay/config.yaml new file mode 100644 index 0000000..bb780f6 --- /dev/null +++ b/integration-tests/tests/entityHtmlDisplay/config.yaml @@ -0,0 +1,25 @@ +# +# Integration Test Config: entity HTML in display components (#804) +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +entities: + - file: entities/html_display.yaml + name: html_display + +actions: [] + +dashboards: + - title: Html Display Dashboard + contents: + - title: Compare result + type: fieldset + entity: html_display + contents: + - type: display + cssClass: test-html-display + title: '{{ html_display.content }}' diff --git a/integration-tests/tests/entityHtmlDisplay/entities/html_display.yaml b/integration-tests/tests/entityHtmlDisplay/entities/html_display.yaml new file mode 100644 index 0000000..99ce2f3 --- /dev/null +++ b/integration-tests/tests/entityHtmlDisplay/entities/html_display.yaml @@ -0,0 +1,2 @@ +- content: | +
entity-html-test
diff --git a/integration-tests/tests/entityHtmlDisplay/entityHtmlDisplay.mjs b/integration-tests/tests/entityHtmlDisplay/entityHtmlDisplay.mjs new file mode 100644 index 0000000..863c5c5 --- /dev/null +++ b/integration-tests/tests/entityHtmlDisplay/entityHtmlDisplay.mjs @@ -0,0 +1,34 @@ +import { describe, it, before, after, afterEach } from 'mocha' +import { expect } from 'chai' +import { By } from 'selenium-webdriver' +import { + getRootAndWait, + takeScreenshotOnFailure, + waitForDashboardLoaded, +} from '../../lib/elements.js' + +describe('config: entityHtmlDisplay', function () { + before(async function () { + await runner.start('entityHtmlDisplay') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('renders entity HTML content inside display components (#804)', async function () { + await getRootAndWait() + await webdriver.get(runner.baseUrl() + 'dashboards/Html%20Display%20Dashboard') + await waitForDashboardLoaded() + + const contentDiv = await webdriver.findElements(By.css('.display.test-html-display .content')) + expect(contentDiv).to.have.length.at.least(1, 'Entity HTML should render inside the display component') + + const text = await contentDiv[0].getText() + expect(text).to.equal('entity-html-test') + }) +}) diff --git a/integration-tests/tests/general/general.mjs b/integration-tests/tests/general/general.mjs index ce892ab..e600818 100644 --- a/integration-tests/tests/general/general.mjs +++ b/integration-tests/tests/general/general.mjs @@ -23,7 +23,7 @@ describe('config: general', function () { }); it('Page title', async function () { - await webdriver.get(runner.baseUrl()) + await getRootAndWait() const title = await webdriver.getTitle() expect(title).to.be.equal("Actions - OliveTin") diff --git a/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs b/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs index 9cdc502..4c3d9ed 100644 --- a/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs +++ b/integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs @@ -2,11 +2,13 @@ import { describe, it, before, after } from 'mocha' import { expect, assert } from 'chai' import { By } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, getActionButtons, getNavigationLinks, openSidebar, takeScreenshotOnFailure, + waitForDashboardLoaded, } from '../../lib/elements.js' describe('config: multi-dashboard-includes', function () { @@ -41,6 +43,7 @@ describe('config: multi-dashboard-includes', function () { assert.strictEqual(matching.length, 1, `Expected exactly one navigation link with title "${title}"`) await matching[0].click() + await waitForDashboardLoaded(DEFAULT_UI_WAIT_MS, title) } async function getActionTitlesOnDashboard (dashboardTitle = null) { diff --git a/integration-tests/tests/multipleDropdowns/multipleDropdowns.js b/integration-tests/tests/multipleDropdowns/multipleDropdowns.js index 25b9901..3c3f6e7 100644 --- a/integration-tests/tests/multipleDropdowns/multipleDropdowns.js +++ b/integration-tests/tests/multipleDropdowns/multipleDropdowns.js @@ -1,8 +1,8 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' -import { By, until, Condition } from 'selenium-webdriver' -import { - getRootAndWait, +import { By, until, Condition, Key } from 'selenium-webdriver' +import { + getRootAndWait, getActionButtons, takeScreenshotOnFailure, } from '../../lib/elements.js' @@ -52,10 +52,22 @@ describe('config: multipleDropdowns', function () { return url.includes('/actionBinding/') && url.includes('/argumentForm') }), 8000) - const selects = await webdriver.findElements(By.css('main select')) - - expect(selects).to.have.length(2) - expect(await selects[0].findElements(By.tagName('option'))).to.have.length(2) - expect(await selects[1].findElements(By.tagName('option'))).to.have.length(3) + const comboboxes = await webdriver.findElements(By.css('main .choice-combobox')) + + expect(comboboxes).to.have.length(2) + + const firstInput = await comboboxes[0].findElement(By.css('.choice-combobox-input')) + await firstInput.click() + await webdriver.wait(new Condition('wait for first combobox list', async () => { + const lists = await comboboxes[0].findElements(By.css('.choice-combobox-list li')) + return lists.length === 2 + }), 2000) + + await firstInput.sendKeys(Key.TAB) + + await webdriver.wait(new Condition('wait for second combobox list', async () => { + const lists = await comboboxes[1].findElements(By.css('.choice-combobox-list li')) + return lists.length === 3 + }), 2000) }) }) diff --git a/integration-tests/tests/suggestionsBrowserKey/config.yaml b/integration-tests/tests/suggestionsBrowserKey/config.yaml index eb2c1df..feadb9f 100644 --- a/integration-tests/tests/suggestionsBrowserKey/config.yaml +++ b/integration-tests/tests/suggestionsBrowserKey/config.yaml @@ -3,6 +3,7 @@ listenAddressSingleHTTPFrontend: 0.0.0.0:1337 logLevel: "DEBUG" checkForUpdates: false +defaultPopupOnStart: execution-dialog actions: - title: Test suggestionsBrowserKey @@ -12,8 +13,10 @@ actions: - name: testInput title: Test Input description: "This input uses suggestionsBrowserKey" + type: ascii_sentence suggestionsBrowserKey: test-suggestions-key - name: testInput2 title: Test Input 2 description: "This input shares the same suggestionsBrowserKey" + type: ascii_sentence suggestionsBrowserKey: test-suggestions-key diff --git a/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs b/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs index c5cd4cf..ffb4712 100644 --- a/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs +++ b/integration-tests/tests/suggestionsBrowserKey/suggestionsBrowserKey.mjs @@ -2,24 +2,74 @@ import { describe, it, before, after } from 'mocha' import { expect } from 'chai' import { By, Condition } from 'selenium-webdriver' import { + DEFAULT_UI_WAIT_MS, getRootAndWait, getActionButton, takeScreenshotOnFailure, - getTerminalBuffer, + waitForDashboardLoaded, + waitForLogsPage, + waitForArgumentFormPage, + waitForArgumentFormReady, + waitForExecutionComplete, } from '../../lib/elements.js' -async function openArgumentForm() { +async function clickBackFromLogsPage() { + const goBackButtons = await webdriver.findElements(By.css('button[title="Go back"]')) + if (goBackButtons.length > 0) { + await goBackButtons[0].click() + return 'history' + } + + const dashboardBackButtons = await webdriver.findElements(By.css('button[title^="Back to "]')) + if (dashboardBackButtons.length > 0) { + await dashboardBackButtons[0].click() + return 'dashboard' + } + + throw new Error('No back button found on execution logs page') +} + +async function ensureOnDashboard() { + let url = await webdriver.getCurrentUrl() + + if (url.includes('/logs/')) { + const backType = await clickBackFromLogsPage() + if (backType === 'history') { + await webdriver.wait( + new Condition('wait for argument form after logs back', async () => { + const currentUrl = await webdriver.getCurrentUrl() + return currentUrl.includes('/argumentForm') + }), + DEFAULT_UI_WAIT_MS + ) + url = await webdriver.getCurrentUrl() + } else { + await waitForDashboardLoaded() + url = await webdriver.getCurrentUrl() + } + } + + if (url.includes('/argumentForm')) { + const cancelButton = await webdriver.findElement(By.css('button[name="cancel"]')) + await cancelButton.click() + await waitForDashboardLoaded() + } + + const actionButtons = await webdriver.findElements(By.css('[title="Test suggestionsBrowserKey"]')) + if (actionButtons.length === 1) { + return + } + await getRootAndWait() +} + +async function openArgumentForm() { + await ensureOnDashboard() const btn = await getActionButton(webdriver, 'Test suggestionsBrowserKey') await btn.click() - await webdriver.wait( - new Condition('wait for argument form page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/actionBinding/') && url.includes('/argumentForm') - }), - 5000 - ) + await waitForArgumentFormPage() + await waitForArgumentFormReady() } async function getTestInput() { @@ -39,41 +89,6 @@ async function submitForm() { await submitButton.click() } -async function waitForLogsPage() { - await webdriver.wait( - new Condition('wait for logs page', async () => { - const url = await webdriver.getCurrentUrl() - return url.includes('/logs/') && !url.endsWith('/logs') - }), - 5000 - ) -} - -async function waitForExecutionComplete() { - await webdriver.wait( - new Condition('wait for execution status', async () => { - const statusElements = await webdriver.findElements(By.id('execution-dialog-status')) - return statusElements.length > 0 - }), - 5000 - ) - - await webdriver.wait( - new Condition('wait for execution to finish', async () => { - try { - const statusElement = await webdriver.findElement(By.id('execution-dialog-status')) - const statusText = await statusElement.getText() - return !statusText.includes('Executing') - } catch (e) { - return false - } - }), - 5000 - ) - - await webdriver.sleep(500) -} - async function getLocalStorageItem(key) { return await webdriver.executeScript(`return localStorage.getItem('${key}')`) } @@ -85,6 +100,7 @@ async function clearLocalStorage() { describe('config: suggestionsBrowserKey', function () { before(async function () { await runner.start('suggestionsBrowserKey') + await getRootAndWait() }) after(async () => { @@ -114,15 +130,13 @@ describe('config: suggestionsBrowserKey', function () { }) it('Submitting form saves value to localStorage', async function () { - this.timeout(15000) - - // Clear localStorage first await clearLocalStorage() - await openArgumentForm() const input = await getTestInput() - const testValue = 'test-value-123' + // Use default argument type "ascii" (alphanumeric only) so tests pass when + // config does not set a looser type (e.g. CI merge base without type lines). + const testValue = 'testvalue123' await input.clear() await input.sendKeys(testValue) @@ -130,36 +144,29 @@ describe('config: suggestionsBrowserKey', function () { await waitForLogsPage() await waitForExecutionComplete() - // Verify value was saved to localStorage const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') expect(stored).to.not.be.null - + const suggestions = JSON.parse(stored) expect(suggestions).to.be.an('array') expect(suggestions).to.include(testValue) }) it('Previously saved values appear in datalist', async function () { - this.timeout(15000) - - // First, save a value to localStorage - const testValue = 'saved-suggestion-456' + const testValue = 'savedsuggestion456' await webdriver.executeScript(` const key = 'olivetin-suggestions-test-suggestions-key'; localStorage.setItem(key, JSON.stringify(['${testValue}'])); `) - // Open the form await openArgumentForm() - // Check that datalist exists and contains the saved value const datalist = await webdriver.findElement(By.id('testInput-choices')) expect(datalist).to.not.be.null const options = await getDatalistOptions() expect(options.length).to.be.greaterThan(0) - // Check if the saved value appears in the datalist let foundValue = false for (const option of options) { const value = await option.getAttribute('value') @@ -172,150 +179,127 @@ describe('config: suggestionsBrowserKey', function () { }) it('Multiple submissions accumulate suggestions', async function () { - this.timeout(20000) - - // Clear localStorage first await clearLocalStorage() - // Submit first value await openArgumentForm() const input1 = await getTestInput() await input1.clear() - await input1.sendKeys('first-value') + await input1.sendKeys('firstvalue') await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Submit second value await openArgumentForm() const input2 = await getTestInput() await input2.clear() - await input2.sendKeys('second-value') + await input2.sendKeys('secondvalue') await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Verify both values are in localStorage const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') expect(stored).to.not.be.null - + const suggestions = JSON.parse(stored) expect(suggestions).to.be.an('array') - expect(suggestions).to.include('first-value') - expect(suggestions).to.include('second-value') - expect(suggestions[0]).to.equal('second-value') // Most recent should be first + expect(suggestions).to.include('firstvalue') + expect(suggestions).to.include('secondvalue') + expect(suggestions[0]).to.equal('secondvalue') }) it('Empty values are not saved to localStorage', async function () { - this.timeout(15000) - - // Clear localStorage first await clearLocalStorage() - await openArgumentForm() const input = await getTestInput() - // Leave input empty (or clear it if it has a default) await input.clear() await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Verify empty value was not saved - localStorage should be null or empty-equivalent const stored = await getLocalStorageItem('olivetin-suggestions-test-suggestions-key') - // Should be null OR empty JSON array string ("[]") OR parse to empty array if (stored !== null) { const suggestions = JSON.parse(stored) expect(suggestions).to.be.an('array') expect(suggestions).to.have.length(0) } - // If stored is null, that's also acceptable - no assertion needed }) it('Suggestions are shared across inputs with the same suggestionsBrowserKey', async function () { - this.timeout(20000) - - // Clear localStorage first + this.timeout(12000) + await clearLocalStorage() - // Submit a value using the first input await openArgumentForm() const input1 = await getTestInput() await input1.clear() - await input1.sendKeys('shared-value-from-input1') + await input1.sendKeys('sharedfrominput1') await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Open the form again and verify the value appears in both datalists await openArgumentForm() - - // Check first input's datalist + const datalist1 = await webdriver.findElement(By.id('testInput-choices')) expect(datalist1).to.not.be.null const options1 = await getDatalistOptions('testInput') let foundInInput1 = false for (const option of options1) { const value = await option.getAttribute('value') - if (value === 'shared-value-from-input1') { + if (value === 'sharedfrominput1') { foundInInput1 = true break } } expect(foundInInput1).to.be.true - // Check second input's datalist const datalist2 = await webdriver.findElement(By.id('testInput2-choices')) expect(datalist2).to.not.be.null const options2 = await getDatalistOptions('testInput2') let foundInInput2 = false for (const option of options2) { const value = await option.getAttribute('value') - if (value === 'shared-value-from-input1') { + if (value === 'sharedfrominput1') { foundInInput2 = true break } } expect(foundInInput2).to.be.true - // Now submit a value using the second input const input2 = await getTestInput2() await input2.clear() - await input2.sendKeys('shared-value-from-input2') + await input2.sendKeys('sharedfrominput2') await submitForm() await waitForLogsPage() await waitForExecutionComplete() - // Verify both values appear in both datalists await openArgumentForm() - - // Check that both values are in the first input's datalist + const options1After = await getDatalistOptions('testInput') let foundValue1 = false let foundValue2 = false for (const option of options1After) { const value = await option.getAttribute('value') - if (value === 'shared-value-from-input1') { + if (value === 'sharedfrominput1') { foundValue1 = true } - if (value === 'shared-value-from-input2') { + if (value === 'sharedfrominput2') { foundValue2 = true } } expect(foundValue1).to.be.true expect(foundValue2).to.be.true - // Check that both values are in the second input's datalist const options2After = await getDatalistOptions('testInput2') foundValue1 = false foundValue2 = false for (const option of options2After) { const value = await option.getAttribute('value') - if (value === 'shared-value-from-input1') { + if (value === 'sharedfrominput1') { foundValue1 = true } - if (value === 'shared-value-from-input2') { + if (value === 'sharedfrominput2') { foundValue2 = true } } diff --git a/integration-tests/tests/xtermLinkHandling/config.yaml b/integration-tests/tests/xtermLinkHandling/config.yaml new file mode 100644 index 0000000..f70848d --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/config.yaml @@ -0,0 +1,13 @@ +# +# Integration Test Config: xterm link handling +# + +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +logLevel: "DEBUG" +checkForUpdates: false + +actions: + - title: Echo URL + shell: echo "See https://example.com for more info" + popupOnStart: execution-dialog-stdout-only diff --git a/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs new file mode 100644 index 0000000..15f30c1 --- /dev/null +++ b/integration-tests/tests/xtermLinkHandling/xtermLinkHandling.mjs @@ -0,0 +1,53 @@ +import { describe, it, before, after } from 'mocha' +import { expect } from 'chai' +import { By, Condition } from 'selenium-webdriver' +import { + DEFAULT_UI_WAIT_MS, + getRootAndWait, + takeScreenshotOnFailure, + getTerminalBuffer, + waitForLogsPage, + waitForExecutionComplete, +} from '../../lib/elements.js' + +describe('config: xtermLinkHandling', function () { + before(async function () { + await runner.start('xtermLinkHandling') + }) + + after(async () => { + await runner.stop() + }) + + afterEach(function () { + takeScreenshotOnFailure(this.currentTest, webdriver) + }) + + it('xterm output shows URL and link handling is configured', async function () { + await getRootAndWait() + + await webdriver.wait(new Condition('wait for Echo URL button', async () => { + const btns = await webdriver.findElements(By.css('[title="Echo URL"]')) + return btns.length === 1 + }), DEFAULT_UI_WAIT_MS) + + const echoUrlButton = await webdriver.findElement(By.css('[title="Echo URL"]')) + await echoUrlButton.click() + + await waitForLogsPage() + await waitForExecutionComplete() + + const bufferText = await getTerminalBuffer() + expect(bufferText).to.not.be.null + expect(bufferText).to.include('https://example.com') + + const linkHandlerSet = await webdriver.executeScript(` + try { + return !!(window.terminal && window.terminal.linkHandlerConfigured === true) + } catch (e) { + return false + } + `) + expect(linkHandlerSet).to.equal(true) + }) +}) diff --git a/lang/combined_output.json b/lang/combined_output.json index 3fda01a..962be84 100644 --- a/lang/combined_output.json +++ b/lang/combined_output.json @@ -20,6 +20,11 @@ "diagnostics.unknown": "Unbekannt", "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData", "diagnostics.where-to-find-help": "Wo Sie Hilfe finden", + "disconnected": "Getrennt", + "disconnected-banner-announcement": "Events-Websocket getrennt.", + "disconnected-banner-link-text": "Events-Websocket getrennt", + "disconnected-banner-suffix": " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " seit {disconnectedSince}. Verbindungsversuch…", "docs": "Dokumentation", "language-dialog.browser-languages": "Browser-Sprachen", "language-dialog.close": "Schließen", @@ -27,6 +32,7 @@ "language-dialog.title": "Sprache auswählen", "login-button": "Login", "logs.action": "Aktion", + "logs.action-group-limits": "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}", "logs.back-to-list": "Zurück zur Liste", "logs.blocked": "Blockiert", "logs.calendar": "Kalender", @@ -38,6 +44,17 @@ "logs.metadata": "Metadaten", "logs.no-logs-to-display": "Es gibt keine Protokolle zu anzeigen.", "logs.page-description": "Dies ist eine Liste von Protokollen von Aktionen, die ausgeführt wurden. Sie können die Liste nach Aktionstitel filtern.", + "logs.queue": "Warteschlange", + "logs.queue-default-group": "Standard", + "logs.queue-empty": "Derzeit gibt es keine aktiven oder wartenden Ausführungen.", + "logs.queue-entity": "Entität", + "logs.queue-group-active": "{active} aktiv (max. {max})", + "logs.queue-group-active-unlimited": "{active} aktiv", + "logs.queue-page-description": "Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet.", + "logs.queue-position": "#{position}", + "logs.queue-running": "Läuft", + "logs.queue-title": "Ausführungswarteschlange", + "logs.queue-waiting": "Wartend", "logs.status": "Status", "logs.timed-out": "Zeitüberschreitung", "logs.timestamp": "Zeitstempel", @@ -47,6 +64,7 @@ "nav.entities": "Entitäten", "nav.logs": "Protokolle", "raise-issue": "Ein Problem melden auf GitHub", + "reconnecting": "Verbinde erneut…", "return-to-index": "Zurück zur Startseite", "search-filter": "Filter aktuelle Seite", "theme-dialog.close": "Schließen", @@ -73,6 +91,11 @@ "diagnostics.unknown": "Unknown", "diagnostics.useragent-data-error": "Error retrieving userAgentData", "diagnostics.where-to-find-help": "Where to find help", + "disconnected": "Disconnected", + "disconnected-banner-announcement": "Events websocket disconnected.", + "disconnected-banner-link-text": "Events websocket disconnected", + "disconnected-banner-suffix": " since {disconnectedSince}. Trying reconnect in {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " since {disconnectedSince}. Trying reconnect…", "docs": "Documentation", "language-dialog.browser-languages": "Browser languages", "language-dialog.close": "Close", @@ -80,6 +103,7 @@ "language-dialog.title": "Select Language", "login-button": "Login", "logs.action": "Action", + "logs.action-group-limits": "concurrent: {concurrent}, queue size: {queueSize}", "logs.back-to-list": "Back to List", "logs.blocked": "Blocked", "logs.calendar": "Calendar", @@ -88,9 +112,28 @@ "logs.clear-filter": "Clear search filter", "logs.completed": "Completed", "logs.exit-code": "Exit code", + "logs.filter-error": "Could not apply filter expression.", + "logs.filter-help-examples": "Examples: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed", + "logs.filter-help-fields": "Fields: Status, Action, User, Output, Blocked, TimedOut, Running, ExitCode. Operators: ==, !=, contains. Prefix ! on a single word excludes matching entries.", + "logs.filter-help-intro": "Filters run on the server over entries you are allowed to view. Combine terms with and / or.", + "logs.filter-help-title": "Filter syntax", + "logs.filter-placeholder": "Filter logs (e.g. !Update or Status != Completed)", "logs.metadata": "Metadata", + "logs.no-logs-for-filter": "No logs match the current filter.", "logs.no-logs-to-display": "There are no logs to display.", - "logs.page-description": "This is a list of logs from actions that have been executed. You can filter the list by action title.", + "logs.page-description": "This is a list of logs from actions that have been executed. Use the filter box for search terms and expressions.", + "logs.queue": "Queue", + "logs.queue-action-details": "Action Details", + "logs.queue-default-group": "Default", + "logs.queue-empty": "There are no active or waiting executions right now.", + "logs.queue-entity": "Entity", + "logs.queue-group-active": "{active} active (max {max})", + "logs.queue-group-active-unlimited": "{active} active", + "logs.queue-page-description": "Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden.", + "logs.queue-position": "#{position}", + "logs.queue-running": "Running", + "logs.queue-title": "Execution Queue", + "logs.queue-waiting": "Waiting", "logs.status": "Status", "logs.timed-out": "Timed out", "logs.timestamp": "Timestamp", @@ -100,6 +143,7 @@ "nav.entities": "Entities", "nav.logs": "Logs", "raise-issue": "Raise an issue on GitHub", + "reconnecting": "Reconnecting…", "return-to-index": "Return to index", "search-filter": "Filter current page", "theme-dialog.close": "Close", @@ -126,6 +170,11 @@ "diagnostics.unknown": "Desconocido", "diagnostics.useragent-data-error": "Error al recuperar userAgentData", "diagnostics.where-to-find-help": "Dónde encontrar ayuda", + "disconnected": "Desconectado", + "disconnected-banner-announcement": "Websocket de eventos desconectado.", + "disconnected-banner-link-text": "Websocket de eventos desconectado", + "disconnected-banner-suffix": " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " desde {disconnectedSince}. Reintentando conexión…", "docs": "Documentación", "language-dialog.browser-languages": "Idiomas del navegador", "language-dialog.close": "Cerrar", @@ -133,6 +182,7 @@ "language-dialog.title": "Seleccionar idioma", "login-button": "Iniciar sesión", "logs.action": "Acción", + "logs.action-group-limits": "simultáneas: {concurrent}, tamaño de cola: {queueSize}", "logs.back-to-list": "Volver a la Lista", "logs.blocked": "Bloqueado", "logs.calendar": "Calendario", @@ -144,6 +194,17 @@ "logs.metadata": "Metadatos", "logs.no-logs-to-display": "No hay registros para mostrar.", "logs.page-description": "Esta es una lista de registros de acciones que han sido ejecutadas. Puede filtrar la lista por título de acción.", + "logs.queue": "Cola", + "logs.queue-default-group": "Predeterminado", + "logs.queue-empty": "No hay ejecuciones activas o en espera en este momento.", + "logs.queue-entity": "Entidad", + "logs.queue-group-active": "{active} activas (máx. {max})", + "logs.queue-group-active-unlimited": "{active} activas", + "logs.queue-page-description": "Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan.", + "logs.queue-position": "#{position}", + "logs.queue-running": "En ejecución", + "logs.queue-title": "Cola de ejecución", + "logs.queue-waiting": "En espera", "logs.status": "Estado", "logs.timed-out": "Tiempo agotado", "logs.timestamp": "Marca de tiempo", @@ -153,6 +214,7 @@ "nav.entities": "Entidades", "nav.logs": "Registros", "raise-issue": "Reportar un problema en GitHub", + "reconnecting": "Reconectando…", "return-to-index": "Volver a la página principal", "search-filter": "Filtrar página actual", "theme-dialog.close": "Cerrar", @@ -179,6 +241,11 @@ "diagnostics.unknown": "Sconosciuto", "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData", "diagnostics.where-to-find-help": "Dove trovare aiuto", + "disconnected": "Disconnesso", + "disconnected-banner-announcement": "Websocket eventi disconnesso.", + "disconnected-banner-link-text": "Websocket eventi disconnesso", + "disconnected-banner-suffix": " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.", + "disconnected-banner-suffix-reconnecting": " dalle {disconnectedSince}. Tentativo di connessione…", "docs": "Documentazione", "language-dialog.browser-languages": "Lingue del browser", "language-dialog.close": "Chiudi", @@ -186,6 +253,7 @@ "language-dialog.title": "Seleziona lingua", "login-button": "Login", "logs.action": "Azione", + "logs.action-group-limits": "simultanei: {concurrent}, dimensione coda: {queueSize}", "logs.back-to-list": "Torna all'Elenco", "logs.blocked": "Bloccato", "logs.calendar": "Calendario", @@ -197,6 +265,17 @@ "logs.metadata": "Metadati", "logs.no-logs-to-display": "Non ci sono registri da mostrare.", "logs.page-description": "Questa è una lista di registri delle azioni che sono state eseguite. Puoi filtrare la lista per titolo dell'azione.", + "logs.queue": "Coda", + "logs.queue-default-group": "Predefinito", + "logs.queue-empty": "Non ci sono esecuzioni attive o in attesa al momento.", + "logs.queue-entity": "Entità", + "logs.queue-group-active": "{active} attive (max {max})", + "logs.queue-group-active-unlimited": "{active} attive", + "logs.queue-page-description": "Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste.", + "logs.queue-position": "#{position}", + "logs.queue-running": "In esecuzione", + "logs.queue-title": "Coda di esecuzione", + "logs.queue-waiting": "In attesa", "logs.status": "Stato", "logs.timed-out": "Tempo scaduto", "logs.timestamp": "Date e ora", @@ -206,6 +285,7 @@ "nav.entities": "Entità", "nav.logs": "Registri", "raise-issue": "Segnala un problema su GitHub", + "reconnecting": "Riconnessione…", "return-to-index": "Torna alla pagina principale", "search-filter": "Filtra la pagina corrente", "theme-dialog.close": "Chiudi", @@ -232,6 +312,11 @@ "diagnostics.unknown": "未知", "diagnostics.useragent-data-error": "检索 userAgentData 时出错", "diagnostics.where-to-find-help": "在哪里找到帮助", + "disconnected": "已断开连接", + "disconnected-banner-announcement": "事件 WebSocket 已断开。", + "disconnected-banner-link-text": "事件 WebSocket 已断开", + "disconnected-banner-suffix": "自 {disconnectedSince}。{reconnectIn} 后尝试重连。", + "disconnected-banner-suffix-reconnecting": "自 {disconnectedSince}。正在尝试重连…", "docs": "文档", "language-dialog.browser-languages": "浏览器语言", "language-dialog.close": "关闭", @@ -239,6 +324,7 @@ "language-dialog.title": "选择语言", "login-button": "登录", "logs.action": "动作", + "logs.action-group-limits": "并发:{concurrent},队列大小:{queueSize}", "logs.back-to-list": "返回列表", "logs.blocked": "阻塞", "logs.calendar": "日历", @@ -250,6 +336,17 @@ "logs.metadata": "元数据", "logs.no-logs-to-display": "没有日志可显示。", "logs.page-description": "这是一个动作执行日志列表。您可以按动作标题过滤列表。", + "logs.queue": "队列", + "logs.queue-default-group": "默认", + "logs.queue-empty": "当前没有正在运行或等待中的执行。", + "logs.queue-entity": "实体", + "logs.queue-group-active": "{active} 个活动(上限 {max})", + "logs.queue-group-active-unlimited": "{active} 个活动", + "logs.queue-page-description": "按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。", + "logs.queue-position": "第 {position} 位", + "logs.queue-running": "运行中", + "logs.queue-title": "执行队列", + "logs.queue-waiting": "等待中", "logs.status": "状态", "logs.timed-out": "超时", "logs.timestamp": "时间戳", @@ -259,6 +356,7 @@ "nav.entities": "实体", "nav.logs": "日志", "raise-issue": "在 GitHub 上报告问题", + "reconnecting": "正在重新连接…", "return-to-index": "返回首页", "search-filter": "过滤当前页面", "theme-dialog.close": "关闭", @@ -267,4 +365,4 @@ "welcome": "欢迎使用 OliveTin" } } -} \ No newline at end of file +} diff --git a/lang/de-DE.yaml b/lang/de-DE.yaml index 2e5f6c7..a328fe0 100644 --- a/lang/de-DE.yaml +++ b/lang/de-DE.yaml @@ -6,6 +6,12 @@ translations: nav.entities: Entitäten nav.diagnostics: Diagnostik connected: Verbunden + disconnected: Getrennt + reconnecting: Verbinde erneut… + disconnected-banner-announcement: Events-Websocket getrennt. + disconnected-banner-link-text: "Events-Websocket getrennt" + disconnected-banner-suffix: " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}." + disconnected-banner-suffix-reconnecting: " seit {disconnectedSince}. Verbindungsversuch…" login-button: Login raise-issue: Ein Problem melden auf GitHub docs: Dokumentation @@ -25,6 +31,18 @@ translations: logs.calendar: Kalender logs.calendar-title: Protokoll-Kalender logs.back-to-list: Zurück zur Liste + logs.queue: Warteschlange + logs.queue-title: Ausführungswarteschlange + logs.queue-page-description: Aktive und wartende Ausführungen, nach Aktionsgruppe gruppiert. Einträge ohne Berechtigung werden ausgeblendet. + logs.queue-default-group: Standard + logs.action-group-limits: "gleichzeitig: {concurrent}, Warteschlangengröße: {queueSize}" + logs.queue-group-active-unlimited: "{active} aktiv" + logs.queue-empty: Derzeit gibt es keine aktiven oder wartenden Ausführungen. + logs.queue-group-active: "{active} aktiv (max. {max})" + logs.queue-waiting: Wartend + logs.queue-running: Läuft + logs.queue-position: "#{position}" + logs.queue-entity: Entität diagnostics.get-support: Unterstützung erhalten diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, einen sosreport von dieser Seite einzufügen. diagnostics.where-to-find-help: Wo Sie Hilfe finden @@ -50,4 +68,4 @@ translations: language-dialog.close: Schließen theme-dialog.title: Design auswählen theme-dialog.default: Standard-Design - theme-dialog.close: Schließen \ No newline at end of file + theme-dialog.close: Schließen diff --git a/lang/en.yaml b/lang/en.yaml index 8cf5c25..b9ab5cf 100644 --- a/lang/en.yaml +++ b/lang/en.yaml @@ -8,9 +8,22 @@ translations: nav.entities: Entities nav.diagnostics: Diagnostics connected: Connected + disconnected: Disconnected + reconnecting: Reconnecting… + disconnected-banner-announcement: Events websocket disconnected. + disconnected-banner-link-text: "Events websocket disconnected" + disconnected-banner-suffix: " since {disconnectedSince}. Trying reconnect in {reconnectIn}." + disconnected-banner-suffix-reconnecting: " since {disconnectedSince}. Trying reconnect…" login-button: Login logs.title: Logs - logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title. + logs.page-description: This is a list of logs from actions that have been executed. Use the filter box for search terms and expressions. + logs.filter-placeholder: Filter logs (e.g. !Update or Status != Completed) + logs.filter-help-title: Filter syntax + logs.filter-help-intro: Filters run on the server over entries you are allowed to view. Combine terms with and / or. + logs.filter-help-fields: "Fields: Status, Action, User, Output, Blocked, TimedOut, Running, ExitCode. Operators: ==, !=, contains. Prefix ! on a single word excludes matching entries." + logs.filter-help-examples: "Examples: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed" + logs.filter-error: Could not apply filter expression. + logs.no-logs-for-filter: No logs match the current filter. logs.timestamp: Timestamp logs.action: Action logs.metadata: Metadata @@ -25,6 +38,19 @@ translations: logs.calendar: Calendar logs.calendar-title: Logs Calendar logs.back-to-list: Back to List + logs.queue: Queue + logs.queue-title: Execution Queue + logs.queue-page-description: Active and waiting executions grouped by action group. Entries you are not permitted to view are hidden. + logs.queue-empty: There are no active or waiting executions right now. + logs.queue-default-group: Default + logs.action-group-limits: "concurrent: {concurrent}, queue size: {queueSize}" + logs.queue-group-active: "{active} active (max {max})" + logs.queue-group-active-unlimited: "{active} active" + logs.queue-waiting: Waiting + logs.queue-running: Running + logs.queue-position: "#{position}" + logs.queue-entity: Entity + logs.queue-action-details: Action Details diagnostics.get-support: Get support diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include a sosreport from this page. diagnostics.where-to-find-help: Where to find help @@ -50,4 +76,4 @@ translations: language-dialog.close: Close theme-dialog.title: Select Theme theme-dialog.default: Default Theme - theme-dialog.close: Close \ No newline at end of file + theme-dialog.close: Close diff --git a/lang/es-ES.yaml b/lang/es-ES.yaml index 974eaf6..3c9e544 100644 --- a/lang/es-ES.yaml +++ b/lang/es-ES.yaml @@ -6,6 +6,12 @@ translations: nav.entities: Entidades nav.diagnostics: Diagnósticos connected: Conectado + disconnected: Desconectado + reconnecting: Reconectando… + disconnected-banner-announcement: Websocket de eventos desconectado. + disconnected-banner-link-text: "Websocket de eventos desconectado" + disconnected-banner-suffix: " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}." + disconnected-banner-suffix-reconnecting: " desde {disconnectedSince}. Reintentando conexión…" login-button: Iniciar sesión raise-issue: Reportar un problema en GitHub docs: Documentación @@ -25,6 +31,18 @@ translations: logs.calendar: Calendario logs.calendar-title: Calendario de Registros logs.back-to-list: Volver a la Lista + logs.queue: Cola + logs.queue-title: Cola de ejecución + logs.queue-page-description: Ejecuciones activas y en espera agrupadas por grupo de acciones. Las entradas que no puede ver se ocultan. + logs.queue-default-group: Predeterminado + logs.action-group-limits: "simultáneas: {concurrent}, tamaño de cola: {queueSize}" + logs.queue-group-active-unlimited: "{active} activas" + logs.queue-empty: No hay ejecuciones activas o en espera en este momento. + logs.queue-group-active: "{active} activas (máx. {max})" + logs.queue-waiting: En espera + logs.queue-running: En ejecución + logs.queue-position: "#{position}" + logs.queue-entity: Entidad diagnostics.get-support: Obtener soporte diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir un sosreport de esta página. diagnostics.where-to-find-help: Dónde encontrar ayuda @@ -50,4 +68,4 @@ translations: language-dialog.close: Cerrar theme-dialog.title: Seleccionar tema theme-dialog.default: Tema Predeterminado - theme-dialog.close: Cerrar \ No newline at end of file + theme-dialog.close: Cerrar diff --git a/lang/it-IT.yaml b/lang/it-IT.yaml index 4bc9c66..2175597 100644 --- a/lang/it-IT.yaml +++ b/lang/it-IT.yaml @@ -7,6 +7,12 @@ translations: nav.diagnostics: Diagnostica docs: Documentazione connected: Connesso + disconnected: Disconnesso + reconnecting: Riconnessione… + disconnected-banner-announcement: Websocket eventi disconnesso. + disconnected-banner-link-text: "Websocket eventi disconnesso" + disconnected-banner-suffix: " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}." + disconnected-banner-suffix-reconnecting: " dalle {disconnectedSince}. Tentativo di connessione…" login-button: Login raise-issue: Segnala un problema su GitHub logs.title: Registri @@ -25,6 +31,18 @@ translations: logs.calendar: Calendario logs.calendar-title: Calendario dei Registri logs.back-to-list: Torna all'Elenco + logs.queue: Coda + logs.queue-title: Coda di esecuzione + logs.queue-page-description: Esecuzioni attive e in attesa raggruppate per gruppo di azioni. Le voci non autorizzate sono nascoste. + logs.queue-default-group: Predefinito + logs.action-group-limits: "simultanei: {concurrent}, dimensione coda: {queueSize}" + logs.queue-group-active-unlimited: "{active} attive" + logs.queue-empty: Non ci sono esecuzioni attive o in attesa al momento. + logs.queue-group-active: "{active} attive (max {max})" + logs.queue-waiting: In attesa + logs.queue-running: In esecuzione + logs.queue-position: "#{position}" + logs.queue-entity: Entità diagnostics.get-support: Ottenere supporto diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere un sosreport da questa pagina. diagnostics.where-to-find-help: Dove trovare aiuto @@ -50,4 +68,4 @@ translations: language-dialog.close: Chiudi theme-dialog.title: Seleziona tema theme-dialog.default: Tema Predefinito - theme-dialog.close: Chiudi \ No newline at end of file + theme-dialog.close: Chiudi diff --git a/lang/zh-Hans-CN.yaml b/lang/zh-Hans-CN.yaml index ad84897..c380548 100644 --- a/lang/zh-Hans-CN.yaml +++ b/lang/zh-Hans-CN.yaml @@ -6,6 +6,12 @@ translations: nav.entities: 实体 nav.diagnostics: 诊断 connected: 已连接 + disconnected: 已断开连接 + reconnecting: 正在重新连接… + disconnected-banner-announcement: 事件 WebSocket 已断开。 + disconnected-banner-link-text: "事件 WebSocket 已断开" + disconnected-banner-suffix: "自 {disconnectedSince}。{reconnectIn} 后尝试重连。" + disconnected-banner-suffix-reconnecting: "自 {disconnectedSince}。正在尝试重连…" login-button: 登录 raise-issue: 在 GitHub 上报告问题 docs: 文档 @@ -34,6 +40,18 @@ translations: logs.calendar: 日历 logs.calendar-title: 日志日历 logs.back-to-list: 返回列表 + logs.queue: 队列 + logs.queue-title: 执行队列 + logs.queue-page-description: 按动作组分组显示正在运行和等待中的执行。您无权查看的条目会被隐藏。 + logs.queue-default-group: 默认 + logs.action-group-limits: "并发:{concurrent},队列大小:{queueSize}" + logs.queue-group-active-unlimited: "{active} 个活动" + logs.queue-empty: 当前没有正在运行或等待中的执行。 + logs.queue-group-active: "{active} 个活动(上限 {max})" + logs.queue-waiting: 等待中 + logs.queue-running: 运行中 + logs.queue-position: "第 {position} 位" + logs.queue-entity: 实体 diagnostics.get-support: 获取支持 diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含 sosreport 将非常有帮助。 diagnostics.where-to-find-help: 在哪里找到帮助 @@ -50,4 +68,4 @@ translations: diagnostics.copy-to-clipboard: 复制到剪贴板 diagnostics.copied: 已复制! diagnostics.unknown: 未知 - diagnostics.useragent-data-error: 检索 userAgentData 时出错 \ No newline at end of file + diagnostics.useragent-data-error: 检索 userAgentData 时出错 diff --git a/local-antora-playbook-ci.yml b/local-antora-playbook-ci.yml new file mode 100644 index 0000000..b63927c --- /dev/null +++ b/local-antora-playbook-ci.yml @@ -0,0 +1,21 @@ +# CI-only Antora playbook: faster and fewer network calls than local-antora-playbook.yml. +# Skips Lunr; disables Kroki diagram fetching so diagram pages still parse without kroki.io. +--- +site: + title: OliveTin docs + start_page: ROOT::index.adoc +content: + sources: + - url: . + branches: HEAD + start_path: docs +ui: + bundle: + url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable + snapshot: true +asciidoc: + attributes: + kroki-fetch-diagram: false + extensions: + - '@asciidoctor/tabs' + - 'asciidoctor-kroki' diff --git a/local-antora-playbook.yml b/local-antora-playbook.yml new file mode 100644 index 0000000..0a8ac46 --- /dev/null +++ b/local-antora-playbook.yml @@ -0,0 +1,26 @@ +# Local / CI Antora playbook: AsciiDoc component under docs/. +# Must live at repository root so content.sources url "." resolves to this git repo. +# The published site is built from github.com/OliveTin/docs.olivetin.app. +--- +site: + title: OliveTin docs + start_page: ROOT::index.adoc +content: + sources: + - url: . + branches: HEAD + start_path: docs +ui: + bundle: + url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable + snapshot: true +antora: + extensions: + - '@antora/lunr-extension' +asciidoc: + attributes: + kroki-fetch-diagram: true + kroki-default-format: png + extensions: + - '@asciidoctor/tabs' + - 'asciidoctor-kroki' diff --git a/proto/olivetin/api/v1/olivetin.proto b/proto/olivetin/api/v1/olivetin.proto index cc12212..c888c2e 100644 --- a/proto/olivetin/api/v1/olivetin.proto +++ b/proto/olivetin/api/v1/olivetin.proto @@ -14,6 +14,29 @@ message Action { int32 order = 7; int32 timeout = 8; string datetime_rate_limit_expires = 9; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" + bool exec_on_startup = 10; + repeated string exec_on_cron = 11; + repeated string exec_on_file_created_in_dir = 12; + repeated string exec_on_file_changed_in_dir = 13; + string exec_on_calendar_file = 14; + repeated ActionWebhookExecHint exec_on_webhooks = 15; + bool justification = 16; + bool has_running_instance = 17; + bool has_queued_instance = 18; + repeated ActionGroupMembership groups = 19; +} + +message ActionGroupMembership { + string name = 1; + int32 max_concurrent = 2; + int32 queue_size = 3; +} + +message ActionWebhookExecHint { + string template = 1; + string match_path = 2; + map match_headers = 3; + map match_query = 4; } message ActionArgument { @@ -51,6 +74,7 @@ message GetDashboardResponse { message EffectivePolicy { bool show_diagnostics = 1; bool show_log_list = 2; + bool show_version_number = 3; } message GetDashboardRequest { @@ -81,6 +105,7 @@ message StartActionRequest { repeated StartActionArgument arguments = 2; string unique_tracking_id = 3; + string justification = 4; } message StartActionArgument { @@ -96,6 +121,8 @@ message StartActionAndWaitRequest { string action_id = 1; repeated StartActionArgument arguments = 2; + + string justification = 3; } message StartActionAndWaitResponse { @@ -121,6 +148,8 @@ message StartActionByGetAndWaitResponse { message GetLogsRequest{ int64 start_offset = 1; string date_filter = 2; // Optional date filter in YYYY-MM-DD format + int64 page_size = 3; // Number of logs per page (optional; server default used if 0 or unset) + string filter = 4; // Optional filter expression (see logs UI syntax help) }; message LogEntry { @@ -142,6 +171,9 @@ message LogEntry { bool can_kill = 18; string datetime_rate_limit_expires = 19; // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" string binding_id = 20; // Binding ID for matching rate limits to action buttons + bool queued = 21; + string queued_for_group = 22; + string justification = 23; } message GetLogsResponse { @@ -165,6 +197,33 @@ message GetActionLogsResponse { int64 start_offset = 5; } +message GetExecutionQueueRequest {} + +message ExecutionQueueAction { + string binding_id = 1; + string action_title = 2; + string action_icon = 3; + int32 max_concurrent = 4; + int32 active_count = 5; + string entity_prefix = 6; + repeated LogEntry entries = 7; +} + +message ExecutionQueueGroup { + string name = 1; + string icon = 2; + int32 max_concurrent = 3; + int32 active_count = 4; + repeated ExecutionQueueAction actions = 5; + int32 queued_count = 6; + int32 queue_size = 7; +} + +message GetExecutionQueueResponse { + repeated ExecutionQueueGroup groups = 1; + int32 total_active = 2; +} + message ValidateArgumentTypeRequest { string value = 1; string type = 2; @@ -190,8 +249,16 @@ message ExecutionStatusRequest { string action_id = 2; } +message DashboardNavigationTarget { + string title = 1; + string entity_type = 2; + string entity_key = 3; + string path = 4; +} + message ExecutionStatusResponse { LogEntry log_entry = 1; + repeated DashboardNavigationTarget back_to_dashboards = 2; } message WhoAmIRequest {} @@ -246,6 +313,7 @@ message EventStreamResponse { EventExecutionFinished execution_finished = 4; EventExecutionStarted execution_started = 5; EventOutputChunk output_chunk = 6; + EventHeartbeat heartbeat = 7; } } @@ -257,6 +325,7 @@ message EventOutputChunk { message EventEntityChanged {} message EventConfigChanged {} +message EventHeartbeat {} message EventExecutionFinished { LogEntry log_entry = 1; } @@ -353,6 +422,7 @@ message GetActionBindingRequest { message GetActionBindingResponse { Action action = 1; + repeated DashboardNavigationTarget back_to_dashboards = 2; } message GetEntitiesRequest { @@ -398,6 +468,8 @@ service OliveTinApiService { rpc GetActionLogs(GetActionLogsRequest) returns (GetActionLogsResponse) {} + rpc GetExecutionQueue(GetExecutionQueueRequest) returns (GetExecutionQueueResponse) {} + rpc ValidateArgumentType(ValidateArgumentTypeRequest) returns (ValidateArgumentTypeResponse) {} rpc WhoAmI(WhoAmIRequest) returns (WhoAmIResponse) {} diff --git a/service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go b/service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go index eed0b68..1182ed2 100644 --- a/service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go +++ b/service/gen/olivetin/api/v1/apiv1connect/olivetin.connect.go @@ -63,6 +63,9 @@ const ( // OliveTinApiServiceGetActionLogsProcedure is the fully-qualified name of the OliveTinApiService's // GetActionLogs RPC. OliveTinApiServiceGetActionLogsProcedure = "/olivetin.api.v1.OliveTinApiService/GetActionLogs" + // OliveTinApiServiceGetExecutionQueueProcedure is the fully-qualified name of the + // OliveTinApiService's GetExecutionQueue RPC. + OliveTinApiServiceGetExecutionQueueProcedure = "/olivetin.api.v1.OliveTinApiService/GetExecutionQueue" // OliveTinApiServiceValidateArgumentTypeProcedure is the fully-qualified name of the // OliveTinApiService's ValidateArgumentType RPC. OliveTinApiServiceValidateArgumentTypeProcedure = "/olivetin.api.v1.OliveTinApiService/ValidateArgumentType" @@ -121,6 +124,7 @@ type OliveTinApiServiceClient interface { ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error) GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error) GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) + GetExecutionQueue(context.Context, *connect.Request[v1.GetExecutionQueueRequest]) (*connect.Response[v1.GetExecutionQueueResponse], error) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error) SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error) @@ -209,6 +213,12 @@ func NewOliveTinApiServiceClient(httpClient connect.HTTPClient, baseURL string, connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")), connect.WithClientOptions(opts...), ), + getExecutionQueue: connect.NewClient[v1.GetExecutionQueueRequest, v1.GetExecutionQueueResponse]( + httpClient, + baseURL+OliveTinApiServiceGetExecutionQueueProcedure, + connect.WithSchema(oliveTinApiServiceMethods.ByName("GetExecutionQueue")), + connect.WithClientOptions(opts...), + ), validateArgumentType: connect.NewClient[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse]( httpClient, baseURL+OliveTinApiServiceValidateArgumentTypeProcedure, @@ -314,6 +324,7 @@ type oliveTinApiServiceClient struct { executionStatus *connect.Client[v1.ExecutionStatusRequest, v1.ExecutionStatusResponse] getLogs *connect.Client[v1.GetLogsRequest, v1.GetLogsResponse] getActionLogs *connect.Client[v1.GetActionLogsRequest, v1.GetActionLogsResponse] + getExecutionQueue *connect.Client[v1.GetExecutionQueueRequest, v1.GetExecutionQueueResponse] validateArgumentType *connect.Client[v1.ValidateArgumentTypeRequest, v1.ValidateArgumentTypeResponse] whoAmI *connect.Client[v1.WhoAmIRequest, v1.WhoAmIResponse] sosReport *connect.Client[v1.SosReportRequest, v1.SosReportResponse] @@ -381,6 +392,11 @@ func (c *oliveTinApiServiceClient) GetActionLogs(ctx context.Context, req *conne return c.getActionLogs.CallUnary(ctx, req) } +// GetExecutionQueue calls olivetin.api.v1.OliveTinApiService.GetExecutionQueue. +func (c *oliveTinApiServiceClient) GetExecutionQueue(ctx context.Context, req *connect.Request[v1.GetExecutionQueueRequest]) (*connect.Response[v1.GetExecutionQueueResponse], error) { + return c.getExecutionQueue.CallUnary(ctx, req) +} + // ValidateArgumentType calls olivetin.api.v1.OliveTinApiService.ValidateArgumentType. func (c *oliveTinApiServiceClient) ValidateArgumentType(ctx context.Context, req *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) { return c.validateArgumentType.CallUnary(ctx, req) @@ -468,6 +484,7 @@ type OliveTinApiServiceHandler interface { ExecutionStatus(context.Context, *connect.Request[v1.ExecutionStatusRequest]) (*connect.Response[v1.ExecutionStatusResponse], error) GetLogs(context.Context, *connect.Request[v1.GetLogsRequest]) (*connect.Response[v1.GetLogsResponse], error) GetActionLogs(context.Context, *connect.Request[v1.GetActionLogsRequest]) (*connect.Response[v1.GetActionLogsResponse], error) + GetExecutionQueue(context.Context, *connect.Request[v1.GetExecutionQueueRequest]) (*connect.Response[v1.GetExecutionQueueResponse], error) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) WhoAmI(context.Context, *connect.Request[v1.WhoAmIRequest]) (*connect.Response[v1.WhoAmIResponse], error) SosReport(context.Context, *connect.Request[v1.SosReportRequest]) (*connect.Response[v1.SosReportResponse], error) @@ -552,6 +569,12 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect connect.WithSchema(oliveTinApiServiceMethods.ByName("GetActionLogs")), connect.WithHandlerOptions(opts...), ) + oliveTinApiServiceGetExecutionQueueHandler := connect.NewUnaryHandler( + OliveTinApiServiceGetExecutionQueueProcedure, + svc.GetExecutionQueue, + connect.WithSchema(oliveTinApiServiceMethods.ByName("GetExecutionQueue")), + connect.WithHandlerOptions(opts...), + ) oliveTinApiServiceValidateArgumentTypeHandler := connect.NewUnaryHandler( OliveTinApiServiceValidateArgumentTypeProcedure, svc.ValidateArgumentType, @@ -664,6 +687,8 @@ func NewOliveTinApiServiceHandler(svc OliveTinApiServiceHandler, opts ...connect oliveTinApiServiceGetLogsHandler.ServeHTTP(w, r) case OliveTinApiServiceGetActionLogsProcedure: oliveTinApiServiceGetActionLogsHandler.ServeHTTP(w, r) + case OliveTinApiServiceGetExecutionQueueProcedure: + oliveTinApiServiceGetExecutionQueueHandler.ServeHTTP(w, r) case OliveTinApiServiceValidateArgumentTypeProcedure: oliveTinApiServiceValidateArgumentTypeHandler.ServeHTTP(w, r) case OliveTinApiServiceWhoAmIProcedure: @@ -743,6 +768,10 @@ func (UnimplementedOliveTinApiServiceHandler) GetActionLogs(context.Context, *co return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetActionLogs is not implemented")) } +func (UnimplementedOliveTinApiServiceHandler) GetExecutionQueue(context.Context, *connect.Request[v1.GetExecutionQueueRequest]) (*connect.Response[v1.GetExecutionQueueResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.GetExecutionQueue is not implemented")) +} + func (UnimplementedOliveTinApiServiceHandler) ValidateArgumentType(context.Context, *connect.Request[v1.ValidateArgumentTypeRequest]) (*connect.Response[v1.ValidateArgumentTypeResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("olivetin.api.v1.OliveTinApiService.ValidateArgumentType is not implemented")) } diff --git a/service/gen/olivetin/api/v1/olivetin.pb.go b/service/gen/olivetin/api/v1/olivetin.pb.go index 5614c01..3505a1c 100644 --- a/service/gen/olivetin/api/v1/olivetin.pb.go +++ b/service/gen/olivetin/api/v1/olivetin.pb.go @@ -22,16 +22,26 @@ const ( ) type Action struct { - state protoimpl.MessageState `protogen:"open.v1"` - BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"` - CanExec bool `protobuf:"varint,4,opt,name=can_exec,json=canExec,proto3" json:"can_exec,omitempty"` - Arguments []*ActionArgument `protobuf:"bytes,5,rep,name=arguments,proto3" json:"arguments,omitempty"` - PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"` - Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"` - Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"` - DatetimeRateLimitExpires string `protobuf:"bytes,9,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" + state protoimpl.MessageState `protogen:"open.v1"` + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` + Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"` + CanExec bool `protobuf:"varint,4,opt,name=can_exec,json=canExec,proto3" json:"can_exec,omitempty"` + Arguments []*ActionArgument `protobuf:"bytes,5,rep,name=arguments,proto3" json:"arguments,omitempty"` + PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"` + Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"` + Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"` + DatetimeRateLimitExpires string `protobuf:"bytes,9,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" + ExecOnStartup bool `protobuf:"varint,10,opt,name=exec_on_startup,json=execOnStartup,proto3" json:"exec_on_startup,omitempty"` + ExecOnCron []string `protobuf:"bytes,11,rep,name=exec_on_cron,json=execOnCron,proto3" json:"exec_on_cron,omitempty"` + ExecOnFileCreatedInDir []string `protobuf:"bytes,12,rep,name=exec_on_file_created_in_dir,json=execOnFileCreatedInDir,proto3" json:"exec_on_file_created_in_dir,omitempty"` + ExecOnFileChangedInDir []string `protobuf:"bytes,13,rep,name=exec_on_file_changed_in_dir,json=execOnFileChangedInDir,proto3" json:"exec_on_file_changed_in_dir,omitempty"` + ExecOnCalendarFile string `protobuf:"bytes,14,opt,name=exec_on_calendar_file,json=execOnCalendarFile,proto3" json:"exec_on_calendar_file,omitempty"` + ExecOnWebhooks []*ActionWebhookExecHint `protobuf:"bytes,15,rep,name=exec_on_webhooks,json=execOnWebhooks,proto3" json:"exec_on_webhooks,omitempty"` + Justification bool `protobuf:"varint,16,opt,name=justification,proto3" json:"justification,omitempty"` + HasRunningInstance bool `protobuf:"varint,17,opt,name=has_running_instance,json=hasRunningInstance,proto3" json:"has_running_instance,omitempty"` + HasQueuedInstance bool `protobuf:"varint,18,opt,name=has_queued_instance,json=hasQueuedInstance,proto3" json:"has_queued_instance,omitempty"` + Groups []*ActionGroupMembership `protobuf:"bytes,19,rep,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -129,6 +139,204 @@ func (x *Action) GetDatetimeRateLimitExpires() string { return "" } +func (x *Action) GetExecOnStartup() bool { + if x != nil { + return x.ExecOnStartup + } + return false +} + +func (x *Action) GetExecOnCron() []string { + if x != nil { + return x.ExecOnCron + } + return nil +} + +func (x *Action) GetExecOnFileCreatedInDir() []string { + if x != nil { + return x.ExecOnFileCreatedInDir + } + return nil +} + +func (x *Action) GetExecOnFileChangedInDir() []string { + if x != nil { + return x.ExecOnFileChangedInDir + } + return nil +} + +func (x *Action) GetExecOnCalendarFile() string { + if x != nil { + return x.ExecOnCalendarFile + } + return "" +} + +func (x *Action) GetExecOnWebhooks() []*ActionWebhookExecHint { + if x != nil { + return x.ExecOnWebhooks + } + return nil +} + +func (x *Action) GetJustification() bool { + if x != nil { + return x.Justification + } + return false +} + +func (x *Action) GetHasRunningInstance() bool { + if x != nil { + return x.HasRunningInstance + } + return false +} + +func (x *Action) GetHasQueuedInstance() bool { + if x != nil { + return x.HasQueuedInstance + } + return false +} + +func (x *Action) GetGroups() []*ActionGroupMembership { + if x != nil { + return x.Groups + } + return nil +} + +type ActionGroupMembership struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + MaxConcurrent int32 `protobuf:"varint,2,opt,name=max_concurrent,json=maxConcurrent,proto3" json:"max_concurrent,omitempty"` + QueueSize int32 `protobuf:"varint,3,opt,name=queue_size,json=queueSize,proto3" json:"queue_size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActionGroupMembership) Reset() { + *x = ActionGroupMembership{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActionGroupMembership) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActionGroupMembership) ProtoMessage() {} + +func (x *ActionGroupMembership) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActionGroupMembership.ProtoReflect.Descriptor instead. +func (*ActionGroupMembership) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{1} +} + +func (x *ActionGroupMembership) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ActionGroupMembership) GetMaxConcurrent() int32 { + if x != nil { + return x.MaxConcurrent + } + return 0 +} + +func (x *ActionGroupMembership) GetQueueSize() int32 { + if x != nil { + return x.QueueSize + } + return 0 +} + +type ActionWebhookExecHint struct { + state protoimpl.MessageState `protogen:"open.v1"` + Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + MatchPath string `protobuf:"bytes,2,opt,name=match_path,json=matchPath,proto3" json:"match_path,omitempty"` + MatchHeaders map[string]string `protobuf:"bytes,3,rep,name=match_headers,json=matchHeaders,proto3" json:"match_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + MatchQuery map[string]string `protobuf:"bytes,4,rep,name=match_query,json=matchQuery,proto3" json:"match_query,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActionWebhookExecHint) Reset() { + *x = ActionWebhookExecHint{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActionWebhookExecHint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActionWebhookExecHint) ProtoMessage() {} + +func (x *ActionWebhookExecHint) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActionWebhookExecHint.ProtoReflect.Descriptor instead. +func (*ActionWebhookExecHint) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{2} +} + +func (x *ActionWebhookExecHint) GetTemplate() string { + if x != nil { + return x.Template + } + return "" +} + +func (x *ActionWebhookExecHint) GetMatchPath() string { + if x != nil { + return x.MatchPath + } + return "" +} + +func (x *ActionWebhookExecHint) GetMatchHeaders() map[string]string { + if x != nil { + return x.MatchHeaders + } + return nil +} + +func (x *ActionWebhookExecHint) GetMatchQuery() map[string]string { + if x != nil { + return x.MatchQuery + } + return nil +} + type ActionArgument struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -145,7 +353,7 @@ type ActionArgument struct { func (x *ActionArgument) Reset() { *x = ActionArgument{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -157,7 +365,7 @@ func (x *ActionArgument) String() string { func (*ActionArgument) ProtoMessage() {} func (x *ActionArgument) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -170,7 +378,7 @@ func (x *ActionArgument) ProtoReflect() protoreflect.Message { // Deprecated: Use ActionArgument.ProtoReflect.Descriptor instead. func (*ActionArgument) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{1} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{3} } func (x *ActionArgument) GetName() string { @@ -239,7 +447,7 @@ type ActionArgumentChoice struct { func (x *ActionArgumentChoice) Reset() { *x = ActionArgumentChoice{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -251,7 +459,7 @@ func (x *ActionArgumentChoice) String() string { func (*ActionArgumentChoice) ProtoMessage() {} func (x *ActionArgumentChoice) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -264,7 +472,7 @@ func (x *ActionArgumentChoice) ProtoReflect() protoreflect.Message { // Deprecated: Use ActionArgumentChoice.ProtoReflect.Descriptor instead. func (*ActionArgumentChoice) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{2} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{4} } func (x *ActionArgumentChoice) GetValue() string { @@ -294,7 +502,7 @@ type Entity struct { func (x *Entity) Reset() { *x = Entity{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -306,7 +514,7 @@ func (x *Entity) String() string { func (*Entity) ProtoMessage() {} func (x *Entity) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -319,7 +527,7 @@ func (x *Entity) ProtoReflect() protoreflect.Message { // Deprecated: Use Entity.ProtoReflect.Descriptor instead. func (*Entity) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{3} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{5} } func (x *Entity) GetTitle() string { @@ -367,7 +575,7 @@ type GetDashboardResponse struct { func (x *GetDashboardResponse) Reset() { *x = GetDashboardResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -379,7 +587,7 @@ func (x *GetDashboardResponse) String() string { func (*GetDashboardResponse) ProtoMessage() {} func (x *GetDashboardResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -392,7 +600,7 @@ func (x *GetDashboardResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDashboardResponse.ProtoReflect.Descriptor instead. func (*GetDashboardResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{4} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{6} } func (x *GetDashboardResponse) GetTitle() string { @@ -410,16 +618,17 @@ func (x *GetDashboardResponse) GetDashboard() *Dashboard { } type EffectivePolicy struct { - state protoimpl.MessageState `protogen:"open.v1"` - ShowDiagnostics bool `protobuf:"varint,1,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"` - ShowLogList bool `protobuf:"varint,2,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + ShowDiagnostics bool `protobuf:"varint,1,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"` + ShowLogList bool `protobuf:"varint,2,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"` + ShowVersionNumber bool `protobuf:"varint,3,opt,name=show_version_number,json=showVersionNumber,proto3" json:"show_version_number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *EffectivePolicy) Reset() { *x = EffectivePolicy{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -431,7 +640,7 @@ func (x *EffectivePolicy) String() string { func (*EffectivePolicy) ProtoMessage() {} func (x *EffectivePolicy) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -444,7 +653,7 @@ func (x *EffectivePolicy) ProtoReflect() protoreflect.Message { // Deprecated: Use EffectivePolicy.ProtoReflect.Descriptor instead. func (*EffectivePolicy) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{5} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{7} } func (x *EffectivePolicy) GetShowDiagnostics() bool { @@ -461,6 +670,13 @@ func (x *EffectivePolicy) GetShowLogList() bool { return false } +func (x *EffectivePolicy) GetShowVersionNumber() bool { + if x != nil { + return x.ShowVersionNumber + } + return false +} + type GetDashboardRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` @@ -472,7 +688,7 @@ type GetDashboardRequest struct { func (x *GetDashboardRequest) Reset() { *x = GetDashboardRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -484,7 +700,7 @@ func (x *GetDashboardRequest) String() string { func (*GetDashboardRequest) ProtoMessage() {} func (x *GetDashboardRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -497,7 +713,7 @@ func (x *GetDashboardRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDashboardRequest.ProtoReflect.Descriptor instead. func (*GetDashboardRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{6} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{8} } func (x *GetDashboardRequest) GetTitle() string { @@ -531,7 +747,7 @@ type Dashboard struct { func (x *Dashboard) Reset() { *x = Dashboard{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -543,7 +759,7 @@ func (x *Dashboard) String() string { func (*Dashboard) ProtoMessage() {} func (x *Dashboard) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -556,7 +772,7 @@ func (x *Dashboard) ProtoReflect() protoreflect.Message { // Deprecated: Use Dashboard.ProtoReflect.Descriptor instead. func (*Dashboard) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{7} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{9} } func (x *Dashboard) GetTitle() string { @@ -589,7 +805,7 @@ type DashboardComponent struct { func (x *DashboardComponent) Reset() { *x = DashboardComponent{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -601,7 +817,7 @@ func (x *DashboardComponent) String() string { func (*DashboardComponent) ProtoMessage() {} func (x *DashboardComponent) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -614,7 +830,7 @@ func (x *DashboardComponent) ProtoReflect() protoreflect.Message { // Deprecated: Use DashboardComponent.ProtoReflect.Descriptor instead. func (*DashboardComponent) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{8} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{10} } func (x *DashboardComponent) GetTitle() string { @@ -678,13 +894,14 @@ type StartActionRequest struct { BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"` UniqueTrackingId string `protobuf:"bytes,3,opt,name=unique_tracking_id,json=uniqueTrackingId,proto3" json:"unique_tracking_id,omitempty"` + Justification string `protobuf:"bytes,4,opt,name=justification,proto3" json:"justification,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartActionRequest) Reset() { *x = StartActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -696,7 +913,7 @@ func (x *StartActionRequest) String() string { func (*StartActionRequest) ProtoMessage() {} func (x *StartActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -709,7 +926,7 @@ func (x *StartActionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionRequest.ProtoReflect.Descriptor instead. func (*StartActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{9} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{11} } func (x *StartActionRequest) GetBindingId() string { @@ -733,6 +950,13 @@ func (x *StartActionRequest) GetUniqueTrackingId() string { return "" } +func (x *StartActionRequest) GetJustification() string { + if x != nil { + return x.Justification + } + return "" +} + type StartActionArgument struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -743,7 +967,7 @@ type StartActionArgument struct { func (x *StartActionArgument) Reset() { *x = StartActionArgument{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -755,7 +979,7 @@ func (x *StartActionArgument) String() string { func (*StartActionArgument) ProtoMessage() {} func (x *StartActionArgument) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -768,7 +992,7 @@ func (x *StartActionArgument) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionArgument.ProtoReflect.Descriptor instead. func (*StartActionArgument) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{10} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{12} } func (x *StartActionArgument) GetName() string { @@ -794,7 +1018,7 @@ type StartActionResponse struct { func (x *StartActionResponse) Reset() { *x = StartActionResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -806,7 +1030,7 @@ func (x *StartActionResponse) String() string { func (*StartActionResponse) ProtoMessage() {} func (x *StartActionResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -819,7 +1043,7 @@ func (x *StartActionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionResponse.ProtoReflect.Descriptor instead. func (*StartActionResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{11} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{13} } func (x *StartActionResponse) GetExecutionTrackingId() string { @@ -833,13 +1057,14 @@ type StartActionAndWaitRequest struct { state protoimpl.MessageState `protogen:"open.v1"` ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"` + Justification string `protobuf:"bytes,3,opt,name=justification,proto3" json:"justification,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartActionAndWaitRequest) Reset() { *x = StartActionAndWaitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -851,7 +1076,7 @@ func (x *StartActionAndWaitRequest) String() string { func (*StartActionAndWaitRequest) ProtoMessage() {} func (x *StartActionAndWaitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -864,7 +1089,7 @@ func (x *StartActionAndWaitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionAndWaitRequest.ProtoReflect.Descriptor instead. func (*StartActionAndWaitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{12} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{14} } func (x *StartActionAndWaitRequest) GetActionId() string { @@ -881,6 +1106,13 @@ func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument { return nil } +func (x *StartActionAndWaitRequest) GetJustification() string { + if x != nil { + return x.Justification + } + return "" +} + type StartActionAndWaitResponse struct { state protoimpl.MessageState `protogen:"open.v1"` LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` @@ -890,7 +1122,7 @@ type StartActionAndWaitResponse struct { func (x *StartActionAndWaitResponse) Reset() { *x = StartActionAndWaitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -902,7 +1134,7 @@ func (x *StartActionAndWaitResponse) String() string { func (*StartActionAndWaitResponse) ProtoMessage() {} func (x *StartActionAndWaitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -915,7 +1147,7 @@ func (x *StartActionAndWaitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionAndWaitResponse.ProtoReflect.Descriptor instead. func (*StartActionAndWaitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{13} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{15} } func (x *StartActionAndWaitResponse) GetLogEntry() *LogEntry { @@ -934,7 +1166,7 @@ type StartActionByGetRequest struct { func (x *StartActionByGetRequest) Reset() { *x = StartActionByGetRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -946,7 +1178,7 @@ func (x *StartActionByGetRequest) String() string { func (*StartActionByGetRequest) ProtoMessage() {} func (x *StartActionByGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -959,7 +1191,7 @@ func (x *StartActionByGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionByGetRequest.ProtoReflect.Descriptor instead. func (*StartActionByGetRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{14} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{16} } func (x *StartActionByGetRequest) GetActionId() string { @@ -978,7 +1210,7 @@ type StartActionByGetResponse struct { func (x *StartActionByGetResponse) Reset() { *x = StartActionByGetResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -990,7 +1222,7 @@ func (x *StartActionByGetResponse) String() string { func (*StartActionByGetResponse) ProtoMessage() {} func (x *StartActionByGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1003,7 +1235,7 @@ func (x *StartActionByGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionByGetResponse.ProtoReflect.Descriptor instead. func (*StartActionByGetResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{15} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{17} } func (x *StartActionByGetResponse) GetExecutionTrackingId() string { @@ -1022,7 +1254,7 @@ type StartActionByGetAndWaitRequest struct { func (x *StartActionByGetAndWaitRequest) Reset() { *x = StartActionByGetAndWaitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1034,7 +1266,7 @@ func (x *StartActionByGetAndWaitRequest) String() string { func (*StartActionByGetAndWaitRequest) ProtoMessage() {} func (x *StartActionByGetAndWaitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1047,7 +1279,7 @@ func (x *StartActionByGetAndWaitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionByGetAndWaitRequest.ProtoReflect.Descriptor instead. func (*StartActionByGetAndWaitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{16} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{18} } func (x *StartActionByGetAndWaitRequest) GetActionId() string { @@ -1066,7 +1298,7 @@ type StartActionByGetAndWaitResponse struct { func (x *StartActionByGetAndWaitResponse) Reset() { *x = StartActionByGetAndWaitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1078,7 +1310,7 @@ func (x *StartActionByGetAndWaitResponse) String() string { func (*StartActionByGetAndWaitResponse) ProtoMessage() {} func (x *StartActionByGetAndWaitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1091,7 +1323,7 @@ func (x *StartActionByGetAndWaitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartActionByGetAndWaitResponse.ProtoReflect.Descriptor instead. func (*StartActionByGetAndWaitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{17} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{19} } func (x *StartActionByGetAndWaitResponse) GetLogEntry() *LogEntry { @@ -1105,13 +1337,15 @@ type GetLogsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` StartOffset int64 `protobuf:"varint,1,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"` DateFilter string `protobuf:"bytes,2,opt,name=date_filter,json=dateFilter,proto3" json:"date_filter,omitempty"` // Optional date filter in YYYY-MM-DD format + PageSize int64 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // Number of logs per page (optional; server default used if 0 or unset) + Filter string `protobuf:"bytes,4,opt,name=filter,proto3" json:"filter,omitempty"` // Optional filter expression (see logs UI syntax help) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *GetLogsRequest) Reset() { *x = GetLogsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1123,7 +1357,7 @@ func (x *GetLogsRequest) String() string { func (*GetLogsRequest) ProtoMessage() {} func (x *GetLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1136,7 +1370,7 @@ func (x *GetLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogsRequest.ProtoReflect.Descriptor instead. func (*GetLogsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{18} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{20} } func (x *GetLogsRequest) GetStartOffset() int64 { @@ -1153,6 +1387,20 @@ func (x *GetLogsRequest) GetDateFilter() string { return "" } +func (x *GetLogsRequest) GetPageSize() int64 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *GetLogsRequest) GetFilter() string { + if x != nil { + return x.Filter + } + return "" +} + type LogEntry struct { state protoimpl.MessageState `protogen:"open.v1"` DatetimeStarted string `protobuf:"bytes,1,opt,name=datetime_started,json=datetimeStarted,proto3" json:"datetime_started,omitempty"` @@ -1173,13 +1421,16 @@ type LogEntry struct { CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"` DatetimeRateLimitExpires string `protobuf:"bytes,19,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons + Queued bool `protobuf:"varint,21,opt,name=queued,proto3" json:"queued,omitempty"` + QueuedForGroup string `protobuf:"bytes,22,opt,name=queued_for_group,json=queuedForGroup,proto3" json:"queued_for_group,omitempty"` + Justification string `protobuf:"bytes,23,opt,name=justification,proto3" json:"justification,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *LogEntry) Reset() { *x = LogEntry{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1191,7 +1442,7 @@ func (x *LogEntry) String() string { func (*LogEntry) ProtoMessage() {} func (x *LogEntry) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1204,7 +1455,7 @@ func (x *LogEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. func (*LogEntry) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{19} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{21} } func (x *LogEntry) GetDatetimeStarted() string { @@ -1333,6 +1584,27 @@ func (x *LogEntry) GetBindingId() string { return "" } +func (x *LogEntry) GetQueued() bool { + if x != nil { + return x.Queued + } + return false +} + +func (x *LogEntry) GetQueuedForGroup() string { + if x != nil { + return x.QueuedForGroup + } + return "" +} + +func (x *LogEntry) GetJustification() string { + if x != nil { + return x.Justification + } + return "" +} + type GetLogsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` @@ -1346,7 +1618,7 @@ type GetLogsResponse struct { func (x *GetLogsResponse) Reset() { *x = GetLogsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1358,7 +1630,7 @@ func (x *GetLogsResponse) String() string { func (*GetLogsResponse) ProtoMessage() {} func (x *GetLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1371,7 +1643,7 @@ func (x *GetLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetLogsResponse.ProtoReflect.Descriptor instead. func (*GetLogsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{20} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{22} } func (x *GetLogsResponse) GetLogs() []*LogEntry { @@ -1419,7 +1691,7 @@ type GetActionLogsRequest struct { func (x *GetActionLogsRequest) Reset() { *x = GetActionLogsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1431,7 +1703,7 @@ func (x *GetActionLogsRequest) String() string { func (*GetActionLogsRequest) ProtoMessage() {} func (x *GetActionLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1444,7 +1716,7 @@ func (x *GetActionLogsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActionLogsRequest.ProtoReflect.Descriptor instead. func (*GetActionLogsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{21} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{23} } func (x *GetActionLogsRequest) GetActionId() string { @@ -1474,7 +1746,7 @@ type GetActionLogsResponse struct { func (x *GetActionLogsResponse) Reset() { *x = GetActionLogsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1486,7 +1758,7 @@ func (x *GetActionLogsResponse) String() string { func (*GetActionLogsResponse) ProtoMessage() {} func (x *GetActionLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1499,7 +1771,7 @@ func (x *GetActionLogsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActionLogsResponse.ProtoReflect.Descriptor instead. func (*GetActionLogsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{22} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{24} } func (x *GetActionLogsResponse) GetLogs() []*LogEntry { @@ -1537,6 +1809,278 @@ func (x *GetActionLogsResponse) GetStartOffset() int64 { return 0 } +type GetExecutionQueueRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetExecutionQueueRequest) Reset() { + *x = GetExecutionQueueRequest{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetExecutionQueueRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExecutionQueueRequest) ProtoMessage() {} + +func (x *GetExecutionQueueRequest) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExecutionQueueRequest.ProtoReflect.Descriptor instead. +func (*GetExecutionQueueRequest) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{25} +} + +type ExecutionQueueAction struct { + state protoimpl.MessageState `protogen:"open.v1"` + BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` + ActionTitle string `protobuf:"bytes,2,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"` + ActionIcon string `protobuf:"bytes,3,opt,name=action_icon,json=actionIcon,proto3" json:"action_icon,omitempty"` + MaxConcurrent int32 `protobuf:"varint,4,opt,name=max_concurrent,json=maxConcurrent,proto3" json:"max_concurrent,omitempty"` + ActiveCount int32 `protobuf:"varint,5,opt,name=active_count,json=activeCount,proto3" json:"active_count,omitempty"` + EntityPrefix string `protobuf:"bytes,6,opt,name=entity_prefix,json=entityPrefix,proto3" json:"entity_prefix,omitempty"` + Entries []*LogEntry `protobuf:"bytes,7,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionQueueAction) Reset() { + *x = ExecutionQueueAction{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionQueueAction) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionQueueAction) ProtoMessage() {} + +func (x *ExecutionQueueAction) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionQueueAction.ProtoReflect.Descriptor instead. +func (*ExecutionQueueAction) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{26} +} + +func (x *ExecutionQueueAction) GetBindingId() string { + if x != nil { + return x.BindingId + } + return "" +} + +func (x *ExecutionQueueAction) GetActionTitle() string { + if x != nil { + return x.ActionTitle + } + return "" +} + +func (x *ExecutionQueueAction) GetActionIcon() string { + if x != nil { + return x.ActionIcon + } + return "" +} + +func (x *ExecutionQueueAction) GetMaxConcurrent() int32 { + if x != nil { + return x.MaxConcurrent + } + return 0 +} + +func (x *ExecutionQueueAction) GetActiveCount() int32 { + if x != nil { + return x.ActiveCount + } + return 0 +} + +func (x *ExecutionQueueAction) GetEntityPrefix() string { + if x != nil { + return x.EntityPrefix + } + return "" +} + +func (x *ExecutionQueueAction) GetEntries() []*LogEntry { + if x != nil { + return x.Entries + } + return nil +} + +type ExecutionQueueGroup struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Icon string `protobuf:"bytes,2,opt,name=icon,proto3" json:"icon,omitempty"` + MaxConcurrent int32 `protobuf:"varint,3,opt,name=max_concurrent,json=maxConcurrent,proto3" json:"max_concurrent,omitempty"` + ActiveCount int32 `protobuf:"varint,4,opt,name=active_count,json=activeCount,proto3" json:"active_count,omitempty"` + Actions []*ExecutionQueueAction `protobuf:"bytes,5,rep,name=actions,proto3" json:"actions,omitempty"` + QueuedCount int32 `protobuf:"varint,6,opt,name=queued_count,json=queuedCount,proto3" json:"queued_count,omitempty"` + QueueSize int32 `protobuf:"varint,7,opt,name=queue_size,json=queueSize,proto3" json:"queue_size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionQueueGroup) Reset() { + *x = ExecutionQueueGroup{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionQueueGroup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionQueueGroup) ProtoMessage() {} + +func (x *ExecutionQueueGroup) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionQueueGroup.ProtoReflect.Descriptor instead. +func (*ExecutionQueueGroup) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{27} +} + +func (x *ExecutionQueueGroup) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ExecutionQueueGroup) GetIcon() string { + if x != nil { + return x.Icon + } + return "" +} + +func (x *ExecutionQueueGroup) GetMaxConcurrent() int32 { + if x != nil { + return x.MaxConcurrent + } + return 0 +} + +func (x *ExecutionQueueGroup) GetActiveCount() int32 { + if x != nil { + return x.ActiveCount + } + return 0 +} + +func (x *ExecutionQueueGroup) GetActions() []*ExecutionQueueAction { + if x != nil { + return x.Actions + } + return nil +} + +func (x *ExecutionQueueGroup) GetQueuedCount() int32 { + if x != nil { + return x.QueuedCount + } + return 0 +} + +func (x *ExecutionQueueGroup) GetQueueSize() int32 { + if x != nil { + return x.QueueSize + } + return 0 +} + +type GetExecutionQueueResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Groups []*ExecutionQueueGroup `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty"` + TotalActive int32 `protobuf:"varint,2,opt,name=total_active,json=totalActive,proto3" json:"total_active,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetExecutionQueueResponse) Reset() { + *x = GetExecutionQueueResponse{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetExecutionQueueResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExecutionQueueResponse) ProtoMessage() {} + +func (x *GetExecutionQueueResponse) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExecutionQueueResponse.ProtoReflect.Descriptor instead. +func (*GetExecutionQueueResponse) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{28} +} + +func (x *GetExecutionQueueResponse) GetGroups() []*ExecutionQueueGroup { + if x != nil { + return x.Groups + } + return nil +} + +func (x *GetExecutionQueueResponse) GetTotalActive() int32 { + if x != nil { + return x.TotalActive + } + return 0 +} + type ValidateArgumentTypeRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` @@ -1549,7 +2093,7 @@ type ValidateArgumentTypeRequest struct { func (x *ValidateArgumentTypeRequest) Reset() { *x = ValidateArgumentTypeRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1561,7 +2105,7 @@ func (x *ValidateArgumentTypeRequest) String() string { func (*ValidateArgumentTypeRequest) ProtoMessage() {} func (x *ValidateArgumentTypeRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1574,7 +2118,7 @@ func (x *ValidateArgumentTypeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateArgumentTypeRequest.ProtoReflect.Descriptor instead. func (*ValidateArgumentTypeRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{23} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{29} } func (x *ValidateArgumentTypeRequest) GetValue() string { @@ -1615,7 +2159,7 @@ type ValidateArgumentTypeResponse struct { func (x *ValidateArgumentTypeResponse) Reset() { *x = ValidateArgumentTypeResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1627,7 +2171,7 @@ func (x *ValidateArgumentTypeResponse) String() string { func (*ValidateArgumentTypeResponse) ProtoMessage() {} func (x *ValidateArgumentTypeResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1640,7 +2184,7 @@ func (x *ValidateArgumentTypeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateArgumentTypeResponse.ProtoReflect.Descriptor instead. func (*ValidateArgumentTypeResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{24} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{30} } func (x *ValidateArgumentTypeResponse) GetValid() bool { @@ -1666,7 +2210,7 @@ type WatchExecutionRequest struct { func (x *WatchExecutionRequest) Reset() { *x = WatchExecutionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1678,7 +2222,7 @@ func (x *WatchExecutionRequest) String() string { func (*WatchExecutionRequest) ProtoMessage() {} func (x *WatchExecutionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1691,7 +2235,7 @@ func (x *WatchExecutionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchExecutionRequest.ProtoReflect.Descriptor instead. func (*WatchExecutionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{25} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{31} } func (x *WatchExecutionRequest) GetExecutionTrackingId() string { @@ -1710,7 +2254,7 @@ type WatchExecutionUpdate struct { func (x *WatchExecutionUpdate) Reset() { *x = WatchExecutionUpdate{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1722,7 +2266,7 @@ func (x *WatchExecutionUpdate) String() string { func (*WatchExecutionUpdate) ProtoMessage() {} func (x *WatchExecutionUpdate) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1735,7 +2279,7 @@ func (x *WatchExecutionUpdate) ProtoReflect() protoreflect.Message { // Deprecated: Use WatchExecutionUpdate.ProtoReflect.Descriptor instead. func (*WatchExecutionUpdate) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{26} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{32} } func (x *WatchExecutionUpdate) GetUpdate() string { @@ -1755,7 +2299,7 @@ type ExecutionStatusRequest struct { func (x *ExecutionStatusRequest) Reset() { *x = ExecutionStatusRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1767,7 +2311,7 @@ func (x *ExecutionStatusRequest) String() string { func (*ExecutionStatusRequest) ProtoMessage() {} func (x *ExecutionStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1780,7 +2324,7 @@ func (x *ExecutionStatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionStatusRequest.ProtoReflect.Descriptor instead. func (*ExecutionStatusRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{27} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{33} } func (x *ExecutionStatusRequest) GetExecutionTrackingId() string { @@ -1797,16 +2341,85 @@ func (x *ExecutionStatusRequest) GetActionId() string { return "" } -type ExecutionStatusResponse struct { +type DashboardNavigationTarget struct { state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` + Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` + EntityType string `protobuf:"bytes,2,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` + EntityKey string `protobuf:"bytes,3,opt,name=entity_key,json=entityKey,proto3" json:"entity_key,omitempty"` + Path string `protobuf:"bytes,4,opt,name=path,proto3" json:"path,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } +func (x *DashboardNavigationTarget) Reset() { + *x = DashboardNavigationTarget{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DashboardNavigationTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DashboardNavigationTarget) ProtoMessage() {} + +func (x *DashboardNavigationTarget) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DashboardNavigationTarget.ProtoReflect.Descriptor instead. +func (*DashboardNavigationTarget) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{34} +} + +func (x *DashboardNavigationTarget) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *DashboardNavigationTarget) GetEntityType() string { + if x != nil { + return x.EntityType + } + return "" +} + +func (x *DashboardNavigationTarget) GetEntityKey() string { + if x != nil { + return x.EntityKey + } + return "" +} + +func (x *DashboardNavigationTarget) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ExecutionStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` + BackToDashboards []*DashboardNavigationTarget `protobuf:"bytes,2,rep,name=back_to_dashboards,json=backToDashboards,proto3" json:"back_to_dashboards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + func (x *ExecutionStatusResponse) Reset() { *x = ExecutionStatusResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1818,7 +2431,7 @@ func (x *ExecutionStatusResponse) String() string { func (*ExecutionStatusResponse) ProtoMessage() {} func (x *ExecutionStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1831,7 +2444,7 @@ func (x *ExecutionStatusResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionStatusResponse.ProtoReflect.Descriptor instead. func (*ExecutionStatusResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{28} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35} } func (x *ExecutionStatusResponse) GetLogEntry() *LogEntry { @@ -1841,6 +2454,13 @@ func (x *ExecutionStatusResponse) GetLogEntry() *LogEntry { return nil } +func (x *ExecutionStatusResponse) GetBackToDashboards() []*DashboardNavigationTarget { + if x != nil { + return x.BackToDashboards + } + return nil +} + type WhoAmIRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1849,7 +2469,7 @@ type WhoAmIRequest struct { func (x *WhoAmIRequest) Reset() { *x = WhoAmIRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1861,7 +2481,7 @@ func (x *WhoAmIRequest) String() string { func (*WhoAmIRequest) ProtoMessage() {} func (x *WhoAmIRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1874,7 +2494,7 @@ func (x *WhoAmIRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WhoAmIRequest.ProtoReflect.Descriptor instead. func (*WhoAmIRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{29} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{36} } type WhoAmIResponse struct { @@ -1890,7 +2510,7 @@ type WhoAmIResponse struct { func (x *WhoAmIResponse) Reset() { *x = WhoAmIResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1902,7 +2522,7 @@ func (x *WhoAmIResponse) String() string { func (*WhoAmIResponse) ProtoMessage() {} func (x *WhoAmIResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1915,7 +2535,7 @@ func (x *WhoAmIResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WhoAmIResponse.ProtoReflect.Descriptor instead. func (*WhoAmIResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{30} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{37} } func (x *WhoAmIResponse) GetAuthenticatedUser() string { @@ -1961,7 +2581,7 @@ type SosReportRequest struct { func (x *SosReportRequest) Reset() { *x = SosReportRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1973,7 +2593,7 @@ func (x *SosReportRequest) String() string { func (*SosReportRequest) ProtoMessage() {} func (x *SosReportRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1986,7 +2606,7 @@ func (x *SosReportRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SosReportRequest.ProtoReflect.Descriptor instead. func (*SosReportRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{31} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{38} } type SosReportResponse struct { @@ -1998,7 +2618,7 @@ type SosReportResponse struct { func (x *SosReportResponse) Reset() { *x = SosReportResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2010,7 +2630,7 @@ func (x *SosReportResponse) String() string { func (*SosReportResponse) ProtoMessage() {} func (x *SosReportResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2023,7 +2643,7 @@ func (x *SosReportResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SosReportResponse.ProtoReflect.Descriptor instead. func (*SosReportResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{32} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{39} } func (x *SosReportResponse) GetAlert() string { @@ -2041,7 +2661,7 @@ type DumpVarsRequest struct { func (x *DumpVarsRequest) Reset() { *x = DumpVarsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2053,7 +2673,7 @@ func (x *DumpVarsRequest) String() string { func (*DumpVarsRequest) ProtoMessage() {} func (x *DumpVarsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2066,7 +2686,7 @@ func (x *DumpVarsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DumpVarsRequest.ProtoReflect.Descriptor instead. func (*DumpVarsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{33} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{40} } type DumpVarsResponse struct { @@ -2079,7 +2699,7 @@ type DumpVarsResponse struct { func (x *DumpVarsResponse) Reset() { *x = DumpVarsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2091,7 +2711,7 @@ func (x *DumpVarsResponse) String() string { func (*DumpVarsResponse) ProtoMessage() {} func (x *DumpVarsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2104,7 +2724,7 @@ func (x *DumpVarsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DumpVarsResponse.ProtoReflect.Descriptor instead. func (*DumpVarsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{34} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{41} } func (x *DumpVarsResponse) GetAlert() string { @@ -2131,7 +2751,7 @@ type DebugBinding struct { func (x *DebugBinding) Reset() { *x = DebugBinding{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2143,7 +2763,7 @@ func (x *DebugBinding) String() string { func (*DebugBinding) ProtoMessage() {} func (x *DebugBinding) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2156,7 +2776,7 @@ func (x *DebugBinding) ProtoReflect() protoreflect.Message { // Deprecated: Use DebugBinding.ProtoReflect.Descriptor instead. func (*DebugBinding) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{42} } func (x *DebugBinding) GetActionTitle() string { @@ -2181,7 +2801,7 @@ type DumpPublicIdActionMapRequest struct { func (x *DumpPublicIdActionMapRequest) Reset() { *x = DumpPublicIdActionMapRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2193,7 +2813,7 @@ func (x *DumpPublicIdActionMapRequest) String() string { func (*DumpPublicIdActionMapRequest) ProtoMessage() {} func (x *DumpPublicIdActionMapRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2206,7 +2826,7 @@ func (x *DumpPublicIdActionMapRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DumpPublicIdActionMapRequest.ProtoReflect.Descriptor instead. func (*DumpPublicIdActionMapRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{36} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{43} } type DumpPublicIdActionMapResponse struct { @@ -2219,7 +2839,7 @@ type DumpPublicIdActionMapResponse struct { func (x *DumpPublicIdActionMapResponse) Reset() { *x = DumpPublicIdActionMapResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2231,7 +2851,7 @@ func (x *DumpPublicIdActionMapResponse) String() string { func (*DumpPublicIdActionMapResponse) ProtoMessage() {} func (x *DumpPublicIdActionMapResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2244,7 +2864,7 @@ func (x *DumpPublicIdActionMapResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DumpPublicIdActionMapResponse.ProtoReflect.Descriptor instead. func (*DumpPublicIdActionMapResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{37} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{44} } func (x *DumpPublicIdActionMapResponse) GetAlert() string { @@ -2269,7 +2889,7 @@ type GetReadyzRequest struct { func (x *GetReadyzRequest) Reset() { *x = GetReadyzRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2281,7 +2901,7 @@ func (x *GetReadyzRequest) String() string { func (*GetReadyzRequest) ProtoMessage() {} func (x *GetReadyzRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2294,7 +2914,7 @@ func (x *GetReadyzRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetReadyzRequest.ProtoReflect.Descriptor instead. func (*GetReadyzRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{38} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{45} } type GetReadyzResponse struct { @@ -2306,7 +2926,7 @@ type GetReadyzResponse struct { func (x *GetReadyzResponse) Reset() { *x = GetReadyzResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2318,7 +2938,7 @@ func (x *GetReadyzResponse) String() string { func (*GetReadyzResponse) ProtoMessage() {} func (x *GetReadyzResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2331,7 +2951,7 @@ func (x *GetReadyzResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetReadyzResponse.ProtoReflect.Descriptor instead. func (*GetReadyzResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{39} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{46} } func (x *GetReadyzResponse) GetStatus() string { @@ -2349,7 +2969,7 @@ type EventStreamRequest struct { func (x *EventStreamRequest) Reset() { *x = EventStreamRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2361,7 +2981,7 @@ func (x *EventStreamRequest) String() string { func (*EventStreamRequest) ProtoMessage() {} func (x *EventStreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2374,7 +2994,7 @@ func (x *EventStreamRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EventStreamRequest.ProtoReflect.Descriptor instead. func (*EventStreamRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{40} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{47} } type EventStreamResponse struct { @@ -2386,6 +3006,7 @@ type EventStreamResponse struct { // *EventStreamResponse_ExecutionFinished // *EventStreamResponse_ExecutionStarted // *EventStreamResponse_OutputChunk + // *EventStreamResponse_Heartbeat Event isEventStreamResponse_Event `protobuf_oneof:"event"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -2393,7 +3014,7 @@ type EventStreamResponse struct { func (x *EventStreamResponse) Reset() { *x = EventStreamResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2405,7 +3026,7 @@ func (x *EventStreamResponse) String() string { func (*EventStreamResponse) ProtoMessage() {} func (x *EventStreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2418,7 +3039,7 @@ func (x *EventStreamResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EventStreamResponse.ProtoReflect.Descriptor instead. func (*EventStreamResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{41} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{48} } func (x *EventStreamResponse) GetEvent() isEventStreamResponse_Event { @@ -2473,6 +3094,15 @@ func (x *EventStreamResponse) GetOutputChunk() *EventOutputChunk { return nil } +func (x *EventStreamResponse) GetHeartbeat() *EventHeartbeat { + if x != nil { + if x, ok := x.Event.(*EventStreamResponse_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + type isEventStreamResponse_Event interface { isEventStreamResponse_Event() } @@ -2497,6 +3127,10 @@ type EventStreamResponse_OutputChunk struct { OutputChunk *EventOutputChunk `protobuf:"bytes,6,opt,name=output_chunk,json=outputChunk,proto3,oneof"` } +type EventStreamResponse_Heartbeat struct { + Heartbeat *EventHeartbeat `protobuf:"bytes,7,opt,name=heartbeat,proto3,oneof"` +} + func (*EventStreamResponse_EntityChanged) isEventStreamResponse_Event() {} func (*EventStreamResponse_ConfigChanged) isEventStreamResponse_Event() {} @@ -2507,6 +3141,8 @@ func (*EventStreamResponse_ExecutionStarted) isEventStreamResponse_Event() {} func (*EventStreamResponse_OutputChunk) isEventStreamResponse_Event() {} +func (*EventStreamResponse_Heartbeat) isEventStreamResponse_Event() {} + type EventOutputChunk struct { state protoimpl.MessageState `protogen:"open.v1"` ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` @@ -2517,7 +3153,7 @@ type EventOutputChunk struct { func (x *EventOutputChunk) Reset() { *x = EventOutputChunk{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2529,7 +3165,7 @@ func (x *EventOutputChunk) String() string { func (*EventOutputChunk) ProtoMessage() {} func (x *EventOutputChunk) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2542,7 +3178,7 @@ func (x *EventOutputChunk) ProtoReflect() protoreflect.Message { // Deprecated: Use EventOutputChunk.ProtoReflect.Descriptor instead. func (*EventOutputChunk) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{42} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{49} } func (x *EventOutputChunk) GetExecutionTrackingId() string { @@ -2567,7 +3203,7 @@ type EventEntityChanged struct { func (x *EventEntityChanged) Reset() { *x = EventEntityChanged{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2579,7 +3215,7 @@ func (x *EventEntityChanged) String() string { func (*EventEntityChanged) ProtoMessage() {} func (x *EventEntityChanged) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2592,7 +3228,7 @@ func (x *EventEntityChanged) ProtoReflect() protoreflect.Message { // Deprecated: Use EventEntityChanged.ProtoReflect.Descriptor instead. func (*EventEntityChanged) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{43} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{50} } type EventConfigChanged struct { @@ -2603,7 +3239,7 @@ type EventConfigChanged struct { func (x *EventConfigChanged) Reset() { *x = EventConfigChanged{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2615,7 +3251,7 @@ func (x *EventConfigChanged) String() string { func (*EventConfigChanged) ProtoMessage() {} func (x *EventConfigChanged) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2628,7 +3264,43 @@ func (x *EventConfigChanged) ProtoReflect() protoreflect.Message { // Deprecated: Use EventConfigChanged.ProtoReflect.Descriptor instead. func (*EventConfigChanged) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{44} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{51} +} + +type EventHeartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EventHeartbeat) Reset() { + *x = EventHeartbeat{} + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EventHeartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EventHeartbeat) ProtoMessage() {} + +func (x *EventHeartbeat) ProtoReflect() protoreflect.Message { + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EventHeartbeat.ProtoReflect.Descriptor instead. +func (*EventHeartbeat) Descriptor() ([]byte, []int) { + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{52} } type EventExecutionFinished struct { @@ -2640,7 +3312,7 @@ type EventExecutionFinished struct { func (x *EventExecutionFinished) Reset() { *x = EventExecutionFinished{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2652,7 +3324,7 @@ func (x *EventExecutionFinished) String() string { func (*EventExecutionFinished) ProtoMessage() {} func (x *EventExecutionFinished) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2665,7 +3337,7 @@ func (x *EventExecutionFinished) ProtoReflect() protoreflect.Message { // Deprecated: Use EventExecutionFinished.ProtoReflect.Descriptor instead. func (*EventExecutionFinished) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{45} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{53} } func (x *EventExecutionFinished) GetLogEntry() *LogEntry { @@ -2684,7 +3356,7 @@ type EventExecutionStarted struct { func (x *EventExecutionStarted) Reset() { *x = EventExecutionStarted{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2696,7 +3368,7 @@ func (x *EventExecutionStarted) String() string { func (*EventExecutionStarted) ProtoMessage() {} func (x *EventExecutionStarted) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2709,7 +3381,7 @@ func (x *EventExecutionStarted) ProtoReflect() protoreflect.Message { // Deprecated: Use EventExecutionStarted.ProtoReflect.Descriptor instead. func (*EventExecutionStarted) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{46} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{54} } func (x *EventExecutionStarted) GetLogEntry() *LogEntry { @@ -2728,7 +3400,7 @@ type KillActionRequest struct { func (x *KillActionRequest) Reset() { *x = KillActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2740,7 +3412,7 @@ func (x *KillActionRequest) String() string { func (*KillActionRequest) ProtoMessage() {} func (x *KillActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2753,7 +3425,7 @@ func (x *KillActionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use KillActionRequest.ProtoReflect.Descriptor instead. func (*KillActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{47} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{55} } func (x *KillActionRequest) GetExecutionTrackingId() string { @@ -2775,7 +3447,7 @@ type KillActionResponse struct { func (x *KillActionResponse) Reset() { *x = KillActionResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2787,7 +3459,7 @@ func (x *KillActionResponse) String() string { func (*KillActionResponse) ProtoMessage() {} func (x *KillActionResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2800,7 +3472,7 @@ func (x *KillActionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KillActionResponse.ProtoReflect.Descriptor instead. func (*KillActionResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{48} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{56} } func (x *KillActionResponse) GetExecutionTrackingId() string { @@ -2841,7 +3513,7 @@ type LocalUserLoginRequest struct { func (x *LocalUserLoginRequest) Reset() { *x = LocalUserLoginRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2853,7 +3525,7 @@ func (x *LocalUserLoginRequest) String() string { func (*LocalUserLoginRequest) ProtoMessage() {} func (x *LocalUserLoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2866,7 +3538,7 @@ func (x *LocalUserLoginRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalUserLoginRequest.ProtoReflect.Descriptor instead. func (*LocalUserLoginRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{49} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{57} } func (x *LocalUserLoginRequest) GetUsername() string { @@ -2892,7 +3564,7 @@ type LocalUserLoginResponse struct { func (x *LocalUserLoginResponse) Reset() { *x = LocalUserLoginResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2904,7 +3576,7 @@ func (x *LocalUserLoginResponse) String() string { func (*LocalUserLoginResponse) ProtoMessage() {} func (x *LocalUserLoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2917,7 +3589,7 @@ func (x *LocalUserLoginResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LocalUserLoginResponse.ProtoReflect.Descriptor instead. func (*LocalUserLoginResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{50} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{58} } func (x *LocalUserLoginResponse) GetSuccess() bool { @@ -2936,7 +3608,7 @@ type PasswordHashRequest struct { func (x *PasswordHashRequest) Reset() { *x = PasswordHashRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2948,7 +3620,7 @@ func (x *PasswordHashRequest) String() string { func (*PasswordHashRequest) ProtoMessage() {} func (x *PasswordHashRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2961,7 +3633,7 @@ func (x *PasswordHashRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PasswordHashRequest.ProtoReflect.Descriptor instead. func (*PasswordHashRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{51} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{59} } func (x *PasswordHashRequest) GetPassword() string { @@ -2980,7 +3652,7 @@ type PasswordHashResponse struct { func (x *PasswordHashResponse) Reset() { *x = PasswordHashResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2992,7 +3664,7 @@ func (x *PasswordHashResponse) String() string { func (*PasswordHashResponse) ProtoMessage() {} func (x *PasswordHashResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3005,7 +3677,7 @@ func (x *PasswordHashResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PasswordHashResponse.ProtoReflect.Descriptor instead. func (*PasswordHashResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{52} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{60} } func (x *PasswordHashResponse) GetHash() string { @@ -3023,7 +3695,7 @@ type LogoutRequest struct { func (x *LogoutRequest) Reset() { *x = LogoutRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3035,7 +3707,7 @@ func (x *LogoutRequest) String() string { func (*LogoutRequest) ProtoMessage() {} func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3048,7 +3720,7 @@ func (x *LogoutRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{53} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{61} } type LogoutResponse struct { @@ -3059,7 +3731,7 @@ type LogoutResponse struct { func (x *LogoutResponse) Reset() { *x = LogoutResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3071,7 +3743,7 @@ func (x *LogoutResponse) String() string { func (*LogoutResponse) ProtoMessage() {} func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3084,7 +3756,7 @@ func (x *LogoutResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{54} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{62} } type GetDiagnosticsRequest struct { @@ -3095,7 +3767,7 @@ type GetDiagnosticsRequest struct { func (x *GetDiagnosticsRequest) Reset() { *x = GetDiagnosticsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3107,7 +3779,7 @@ func (x *GetDiagnosticsRequest) String() string { func (*GetDiagnosticsRequest) ProtoMessage() {} func (x *GetDiagnosticsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3120,7 +3792,7 @@ func (x *GetDiagnosticsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDiagnosticsRequest.ProtoReflect.Descriptor instead. func (*GetDiagnosticsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{55} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{63} } type GetDiagnosticsResponse struct { @@ -3133,7 +3805,7 @@ type GetDiagnosticsResponse struct { func (x *GetDiagnosticsResponse) Reset() { *x = GetDiagnosticsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3145,7 +3817,7 @@ func (x *GetDiagnosticsResponse) String() string { func (*GetDiagnosticsResponse) ProtoMessage() {} func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3158,7 +3830,7 @@ func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead. func (*GetDiagnosticsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{56} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64} } func (x *GetDiagnosticsResponse) GetSshFoundKey() string { @@ -3183,7 +3855,7 @@ type InitRequest struct { func (x *InitRequest) Reset() { *x = InitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3195,7 +3867,7 @@ func (x *InitRequest) String() string { func (*InitRequest) ProtoMessage() {} func (x *InitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3208,7 +3880,7 @@ func (x *InitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead. func (*InitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{57} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65} } type InitResponse struct { @@ -3244,7 +3916,7 @@ type InitResponse struct { func (x *InitResponse) Reset() { *x = InitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3256,7 +3928,7 @@ func (x *InitResponse) String() string { func (*InitResponse) ProtoMessage() {} func (x *InitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3269,7 +3941,7 @@ func (x *InitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InitResponse.ProtoReflect.Descriptor instead. func (*InitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{58} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66} } func (x *InitResponse) GetShowFooter() bool { @@ -3457,7 +4129,7 @@ type AdditionalLink struct { func (x *AdditionalLink) Reset() { *x = AdditionalLink{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3469,7 +4141,7 @@ func (x *AdditionalLink) String() string { func (*AdditionalLink) ProtoMessage() {} func (x *AdditionalLink) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3482,7 +4154,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message { // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead. func (*AdditionalLink) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{59} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67} } func (x *AdditionalLink) GetTitle() string { @@ -3510,7 +4182,7 @@ type OAuth2Provider struct { func (x *OAuth2Provider) Reset() { *x = OAuth2Provider{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3522,7 +4194,7 @@ func (x *OAuth2Provider) String() string { func (*OAuth2Provider) ProtoMessage() {} func (x *OAuth2Provider) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3535,7 +4207,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message { // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead. func (*OAuth2Provider) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{60} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{68} } func (x *OAuth2Provider) GetTitle() string { @@ -3568,7 +4240,7 @@ type GetActionBindingRequest struct { func (x *GetActionBindingRequest) Reset() { *x = GetActionBindingRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3580,7 +4252,7 @@ func (x *GetActionBindingRequest) String() string { func (*GetActionBindingRequest) ProtoMessage() {} func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3593,7 +4265,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead. func (*GetActionBindingRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{61} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69} } func (x *GetActionBindingRequest) GetBindingId() string { @@ -3604,15 +4276,16 @@ func (x *GetActionBindingRequest) GetBindingId() string { } type GetActionBindingResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Action *Action `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Action *Action `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` + BackToDashboards []*DashboardNavigationTarget `protobuf:"bytes,2,rep,name=back_to_dashboards,json=backToDashboards,proto3" json:"back_to_dashboards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetActionBindingResponse) Reset() { *x = GetActionBindingResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3624,7 +4297,7 @@ func (x *GetActionBindingResponse) String() string { func (*GetActionBindingResponse) ProtoMessage() {} func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3637,7 +4310,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead. func (*GetActionBindingResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{62} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70} } func (x *GetActionBindingResponse) GetAction() *Action { @@ -3647,6 +4320,13 @@ func (x *GetActionBindingResponse) GetAction() *Action { return nil } +func (x *GetActionBindingResponse) GetBackToDashboards() []*DashboardNavigationTarget { + if x != nil { + return x.BackToDashboards + } + return nil +} + type GetEntitiesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -3655,7 +4335,7 @@ type GetEntitiesRequest struct { func (x *GetEntitiesRequest) Reset() { *x = GetEntitiesRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3667,7 +4347,7 @@ func (x *GetEntitiesRequest) String() string { func (*GetEntitiesRequest) ProtoMessage() {} func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3680,7 +4360,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead. func (*GetEntitiesRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{63} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71} } type GetEntitiesResponse struct { @@ -3692,7 +4372,7 @@ type GetEntitiesResponse struct { func (x *GetEntitiesResponse) Reset() { *x = GetEntitiesResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3704,7 +4384,7 @@ func (x *GetEntitiesResponse) String() string { func (*GetEntitiesResponse) ProtoMessage() {} func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3717,7 +4397,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead. func (*GetEntitiesResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72} } func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition { @@ -3738,7 +4418,7 @@ type EntityDefinition struct { func (x *EntityDefinition) Reset() { *x = EntityDefinition{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3750,7 +4430,7 @@ func (x *EntityDefinition) String() string { func (*EntityDefinition) ProtoMessage() {} func (x *EntityDefinition) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3763,7 +4443,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message { // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead. func (*EntityDefinition) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73} } func (x *EntityDefinition) GetTitle() string { @@ -3797,7 +4477,7 @@ type GetEntityRequest struct { func (x *GetEntityRequest) Reset() { *x = GetEntityRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3809,7 +4489,7 @@ func (x *GetEntityRequest) String() string { func (*GetEntityRequest) ProtoMessage() {} func (x *GetEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3822,7 +4502,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead. func (*GetEntityRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74} } func (x *GetEntityRequest) GetUniqueKey() string { @@ -3848,7 +4528,7 @@ type RestartActionRequest struct { func (x *RestartActionRequest) Reset() { *x = RestartActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3860,7 +4540,7 @@ func (x *RestartActionRequest) String() string { func (*RestartActionRequest) ProtoMessage() {} func (x *RestartActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] + mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3873,7 +4553,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead. func (*RestartActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67} + return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75} } func (x *RestartActionRequest) GetExecutionTrackingId() string { @@ -3887,7 +4567,7 @@ var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\n" + - "\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\xc0\x02\n" + + "\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\xd1\x06\n" + "\x06Action\x12\x1d\n" + "\n" + "binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" + @@ -3898,7 +4578,37 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" + "\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" + "\atimeout\x18\b \x01(\x05R\atimeout\x12=\n" + - "\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xa2\x03\n" + + "\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\x12&\n" + + "\x0fexec_on_startup\x18\n" + + " \x01(\bR\rexecOnStartup\x12 \n" + + "\fexec_on_cron\x18\v \x03(\tR\n" + + "execOnCron\x12;\n" + + "\x1bexec_on_file_created_in_dir\x18\f \x03(\tR\x16execOnFileCreatedInDir\x12;\n" + + "\x1bexec_on_file_changed_in_dir\x18\r \x03(\tR\x16execOnFileChangedInDir\x121\n" + + "\x15exec_on_calendar_file\x18\x0e \x01(\tR\x12execOnCalendarFile\x12P\n" + + "\x10exec_on_webhooks\x18\x0f \x03(\v2&.olivetin.api.v1.ActionWebhookExecHintR\x0eexecOnWebhooks\x12$\n" + + "\rjustification\x18\x10 \x01(\bR\rjustification\x120\n" + + "\x14has_running_instance\x18\x11 \x01(\bR\x12hasRunningInstance\x12.\n" + + "\x13has_queued_instance\x18\x12 \x01(\bR\x11hasQueuedInstance\x12>\n" + + "\x06groups\x18\x13 \x03(\v2&.olivetin.api.v1.ActionGroupMembershipR\x06groups\"q\n" + + "\x15ActionGroupMembership\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12%\n" + + "\x0emax_concurrent\x18\x02 \x01(\x05R\rmaxConcurrent\x12\x1d\n" + + "\n" + + "queue_size\x18\x03 \x01(\x05R\tqueueSize\"\x8a\x03\n" + + "\x15ActionWebhookExecHint\x12\x1a\n" + + "\btemplate\x18\x01 \x01(\tR\btemplate\x12\x1d\n" + + "\n" + + "match_path\x18\x02 \x01(\tR\tmatchPath\x12]\n" + + "\rmatch_headers\x18\x03 \x03(\v28.olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntryR\fmatchHeaders\x12W\n" + + "\vmatch_query\x18\x04 \x03(\v26.olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntryR\n" + + "matchQuery\x1a?\n" + + "\x11MatchHeadersEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a=\n" + + "\x0fMatchQueryEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa2\x03\n" + "\x0eActionArgument\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + @@ -3926,10 +4636,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"f\n" + "\x14GetDashboardResponse\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x128\n" + - "\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"`\n" + + "\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"\x90\x01\n" + "\x0fEffectivePolicy\x12)\n" + "\x10show_diagnostics\x18\x01 \x01(\bR\x0fshowDiagnostics\x12\"\n" + - "\rshow_log_list\x18\x02 \x01(\bR\vshowLogList\"k\n" + + "\rshow_log_list\x18\x02 \x01(\bR\vshowLogList\x12.\n" + + "\x13show_version_number\x18\x03 \x01(\bR\x11showVersionNumber\"k\n" + "\x13GetDashboardRequest\x12\x14\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x1f\n" + "\ventity_type\x18\x02 \x01(\tR\n" + @@ -3949,20 +4660,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\ventity_type\x18\a \x01(\tR\n" + "entityType\x12\x1d\n" + "\n" + - "entity_key\x18\b \x01(\tR\tentityKey\"\xa5\x01\n" + + "entity_key\x18\b \x01(\tR\tentityKey\"\xcb\x01\n" + "\x12StartActionRequest\x12\x1d\n" + "\n" + "binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" + "\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\n" + - "\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\"?\n" + + "\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\x12$\n" + + "\rjustification\x18\x04 \x01(\tR\rjustification\"?\n" + "\x13StartActionArgument\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value\"I\n" + "\x13StartActionResponse\x122\n" + - "\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"|\n" + + "\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"\xa2\x01\n" + "\x19StartActionAndWaitRequest\x12\x1b\n" + "\taction_id\x18\x01 \x01(\tR\bactionId\x12B\n" + - "\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"T\n" + + "\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12$\n" + + "\rjustification\x18\x03 \x01(\tR\rjustification\"T\n" + "\x1aStartActionAndWaitResponse\x126\n" + "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" + "\x17StartActionByGetRequest\x12\x1b\n" + @@ -3972,11 +4685,13 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x1eStartActionByGetAndWaitRequest\x12\x1b\n" + "\taction_id\x18\x01 \x01(\tR\bactionId\"Y\n" + "\x1fStartActionByGetAndWaitResponse\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"T\n" + + "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"\x89\x01\n" + "\x0eGetLogsRequest\x12!\n" + "\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\x12\x1f\n" + "\vdate_filter\x18\x02 \x01(\tR\n" + - "dateFilter\"\x89\x05\n" + + "dateFilter\x12\x1b\n" + + "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x16\n" + + "\x06filter\x18\x04 \x01(\tR\x06filter\"\xf1\x05\n" + "\bLogEntry\x12)\n" + "\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" + "\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" + @@ -3999,7 +4714,10 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\bcan_kill\x18\x12 \x01(\bR\acanKill\x12=\n" + "\x1bdatetime_rate_limit_expires\x18\x13 \x01(\tR\x18datetimeRateLimitExpires\x12\x1d\n" + "\n" + - "binding_id\x18\x14 \x01(\tR\tbindingId\"\xca\x01\n" + + "binding_id\x18\x14 \x01(\tR\tbindingId\x12\x16\n" + + "\x06queued\x18\x15 \x01(\bR\x06queued\x12(\n" + + "\x10queued_for_group\x18\x16 \x01(\tR\x0equeuedForGroup\x12$\n" + + "\rjustification\x18\x17 \x01(\tR\rjustification\"\xca\x01\n" + "\x0fGetLogsResponse\x12-\n" + "\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" + "\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" + @@ -4016,7 +4734,30 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x1f\n" + "\vtotal_count\x18\x04 \x01(\x03R\n" + "totalCount\x12!\n" + - "\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"\x8b\x01\n" + + "\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"\x1a\n" + + "\x18GetExecutionQueueRequest\"\x9d\x02\n" + + "\x14ExecutionQueueAction\x12\x1d\n" + + "\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\x12!\n" + + "\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x1f\n" + + "\vaction_icon\x18\x03 \x01(\tR\n" + + "actionIcon\x12%\n" + + "\x0emax_concurrent\x18\x04 \x01(\x05R\rmaxConcurrent\x12!\n" + + "\factive_count\x18\x05 \x01(\x05R\vactiveCount\x12#\n" + + "\rentity_prefix\x18\x06 \x01(\tR\fentityPrefix\x123\n" + + "\aentries\x18\a \x03(\v2\x19.olivetin.api.v1.LogEntryR\aentries\"\x8a\x02\n" + + "\x13ExecutionQueueGroup\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04icon\x18\x02 \x01(\tR\x04icon\x12%\n" + + "\x0emax_concurrent\x18\x03 \x01(\x05R\rmaxConcurrent\x12!\n" + + "\factive_count\x18\x04 \x01(\x05R\vactiveCount\x12?\n" + + "\aactions\x18\x05 \x03(\v2%.olivetin.api.v1.ExecutionQueueActionR\aactions\x12!\n" + + "\fqueued_count\x18\x06 \x01(\x05R\vqueuedCount\x12\x1d\n" + + "\n" + + "queue_size\x18\a \x01(\x05R\tqueueSize\"|\n" + + "\x19GetExecutionQueueResponse\x12<\n" + + "\x06groups\x18\x01 \x03(\v2$.olivetin.api.v1.ExecutionQueueGroupR\x06groups\x12!\n" + + "\ftotal_active\x18\x02 \x01(\x05R\vtotalActive\"\x8b\x01\n" + "\x1bValidateArgumentTypeRequest\x12\x14\n" + "\x05value\x18\x01 \x01(\tR\x05value\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\x12\x1d\n" + @@ -4032,9 +4773,17 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x06update\x18\x01 \x01(\tR\x06update\"i\n" + "\x16ExecutionStatusRequest\x122\n" + "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x1b\n" + - "\taction_id\x18\x02 \x01(\tR\bactionId\"Q\n" + + "\taction_id\x18\x02 \x01(\tR\bactionId\"\x85\x01\n" + + "\x19DashboardNavigationTarget\x12\x14\n" + + "\x05title\x18\x01 \x01(\tR\x05title\x12\x1f\n" + + "\ventity_type\x18\x02 \x01(\tR\n" + + "entityType\x12\x1d\n" + + "\n" + + "entity_key\x18\x03 \x01(\tR\tentityKey\x12\x12\n" + + "\x04path\x18\x04 \x01(\tR\x04path\"\xab\x01\n" + "\x17ExecutionStatusResponse\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"\x0f\n" + + "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\x12X\n" + + "\x12back_to_dashboards\x18\x02 \x03(\v2*.olivetin.api.v1.DashboardNavigationTargetR\x10backToDashboards\"\x0f\n" + "\rWhoAmIRequest\"\x9f\x01\n" + "\x0eWhoAmIResponse\x12-\n" + "\x12authenticated_user\x18\x01 \x01(\tR\x11authenticatedUser\x12\x1c\n" + @@ -4065,19 +4814,21 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x10GetReadyzRequest\"+\n" + "\x11GetReadyzResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" + - "\x12EventStreamRequest\"\xb3\x03\n" + + "\x12EventStreamRequest\"\xf4\x03\n" + "\x13EventStreamResponse\x12L\n" + "\x0eentity_changed\x18\x02 \x01(\v2#.olivetin.api.v1.EventEntityChangedH\x00R\rentityChanged\x12L\n" + "\x0econfig_changed\x18\x03 \x01(\v2#.olivetin.api.v1.EventConfigChangedH\x00R\rconfigChanged\x12X\n" + "\x12execution_finished\x18\x04 \x01(\v2'.olivetin.api.v1.EventExecutionFinishedH\x00R\x11executionFinished\x12U\n" + "\x11execution_started\x18\x05 \x01(\v2&.olivetin.api.v1.EventExecutionStartedH\x00R\x10executionStarted\x12F\n" + - "\foutput_chunk\x18\x06 \x01(\v2!.olivetin.api.v1.EventOutputChunkH\x00R\voutputChunkB\a\n" + + "\foutput_chunk\x18\x06 \x01(\v2!.olivetin.api.v1.EventOutputChunkH\x00R\voutputChunk\x12?\n" + + "\theartbeat\x18\a \x01(\v2\x1f.olivetin.api.v1.EventHeartbeatH\x00R\theartbeatB\a\n" + "\x05event\"^\n" + "\x10EventOutputChunk\x122\n" + "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x16\n" + "\x06output\x18\x02 \x01(\tR\x06output\"\x14\n" + "\x12EventEntityChanged\"\x14\n" + - "\x12EventConfigChanged\"P\n" + + "\x12EventConfigChanged\"\x10\n" + + "\x0eEventHeartbeat\"P\n" + "\x16EventExecutionFinished\x126\n" + "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"O\n" + "\x15EventExecutionStarted\x126\n" + @@ -4144,9 +4895,10 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "\x03key\x18\x04 \x01(\tR\x03key\"8\n" + "\x17GetActionBindingRequest\x12\x1d\n" + "\n" + - "binding_id\x18\x01 \x01(\tR\tbindingId\"K\n" + + "binding_id\x18\x01 \x01(\tR\tbindingId\"\xa5\x01\n" + "\x18GetActionBindingResponse\x12/\n" + - "\x06action\x18\x01 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\"\x14\n" + + "\x06action\x18\x01 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\x12X\n" + + "\x12back_to_dashboards\x18\x02 \x03(\v2*.olivetin.api.v1.DashboardNavigationTargetR\x10backToDashboards\"\x14\n" + "\x12GetEntitiesRequest\"g\n" + "\x13GetEntitiesResponse\x12P\n" + "\x12entity_definitions\x18\x01 \x03(\v2!.olivetin.api.v1.EntityDefinitionR\x11entityDefinitions\"\x8d\x01\n" + @@ -4159,7 +4911,7 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "unique_key\x18\x01 \x01(\tR\tuniqueKey\x12\x12\n" + "\x04type\x18\x02 \x01(\tR\x04type\"J\n" + "\x14RestartActionRequest\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId2\xe8\x12\n" + + "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId2\xd6\x13\n" + "\x12OliveTinApiService\x12]\n" + "\fGetDashboard\x12$.olivetin.api.v1.GetDashboardRequest\x1a%.olivetin.api.v1.GetDashboardResponse\"\x00\x12Z\n" + "\vStartAction\x12#.olivetin.api.v1.StartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12o\n" + @@ -4171,7 +4923,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + "KillAction\x12\".olivetin.api.v1.KillActionRequest\x1a#.olivetin.api.v1.KillActionResponse\"\x00\x12f\n" + "\x0fExecutionStatus\x12'.olivetin.api.v1.ExecutionStatusRequest\x1a(.olivetin.api.v1.ExecutionStatusResponse\"\x00\x12N\n" + "\aGetLogs\x12\x1f.olivetin.api.v1.GetLogsRequest\x1a .olivetin.api.v1.GetLogsResponse\"\x00\x12`\n" + - "\rGetActionLogs\x12%.olivetin.api.v1.GetActionLogsRequest\x1a&.olivetin.api.v1.GetActionLogsResponse\"\x00\x12u\n" + + "\rGetActionLogs\x12%.olivetin.api.v1.GetActionLogsRequest\x1a&.olivetin.api.v1.GetActionLogsResponse\"\x00\x12l\n" + + "\x11GetExecutionQueue\x12).olivetin.api.v1.GetExecutionQueueRequest\x1a*.olivetin.api.v1.GetExecutionQueueResponse\"\x00\x12u\n" + "\x14ValidateArgumentType\x12,.olivetin.api.v1.ValidateArgumentTypeRequest\x1a-.olivetin.api.v1.ValidateArgumentTypeResponse\"\x00\x12K\n" + "\x06WhoAmI\x12\x1e.olivetin.api.v1.WhoAmIRequest\x1a\x1f.olivetin.api.v1.WhoAmIResponse\"\x00\x12T\n" + "\tSosReport\x12!.olivetin.api.v1.SosReportRequest\x1a\".olivetin.api.v1.SosReportResponse\"\x00\x12Q\n" + @@ -4200,168 +4953,190 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte { return file_olivetin_api_v1_olivetin_proto_rawDescData } -var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 72) +var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 82) var file_olivetin_api_v1_olivetin_proto_goTypes = []any{ (*Action)(nil), // 0: olivetin.api.v1.Action - (*ActionArgument)(nil), // 1: olivetin.api.v1.ActionArgument - (*ActionArgumentChoice)(nil), // 2: olivetin.api.v1.ActionArgumentChoice - (*Entity)(nil), // 3: olivetin.api.v1.Entity - (*GetDashboardResponse)(nil), // 4: olivetin.api.v1.GetDashboardResponse - (*EffectivePolicy)(nil), // 5: olivetin.api.v1.EffectivePolicy - (*GetDashboardRequest)(nil), // 6: olivetin.api.v1.GetDashboardRequest - (*Dashboard)(nil), // 7: olivetin.api.v1.Dashboard - (*DashboardComponent)(nil), // 8: olivetin.api.v1.DashboardComponent - (*StartActionRequest)(nil), // 9: olivetin.api.v1.StartActionRequest - (*StartActionArgument)(nil), // 10: olivetin.api.v1.StartActionArgument - (*StartActionResponse)(nil), // 11: olivetin.api.v1.StartActionResponse - (*StartActionAndWaitRequest)(nil), // 12: olivetin.api.v1.StartActionAndWaitRequest - (*StartActionAndWaitResponse)(nil), // 13: olivetin.api.v1.StartActionAndWaitResponse - (*StartActionByGetRequest)(nil), // 14: olivetin.api.v1.StartActionByGetRequest - (*StartActionByGetResponse)(nil), // 15: olivetin.api.v1.StartActionByGetResponse - (*StartActionByGetAndWaitRequest)(nil), // 16: olivetin.api.v1.StartActionByGetAndWaitRequest - (*StartActionByGetAndWaitResponse)(nil), // 17: olivetin.api.v1.StartActionByGetAndWaitResponse - (*GetLogsRequest)(nil), // 18: olivetin.api.v1.GetLogsRequest - (*LogEntry)(nil), // 19: olivetin.api.v1.LogEntry - (*GetLogsResponse)(nil), // 20: olivetin.api.v1.GetLogsResponse - (*GetActionLogsRequest)(nil), // 21: olivetin.api.v1.GetActionLogsRequest - (*GetActionLogsResponse)(nil), // 22: olivetin.api.v1.GetActionLogsResponse - (*ValidateArgumentTypeRequest)(nil), // 23: olivetin.api.v1.ValidateArgumentTypeRequest - (*ValidateArgumentTypeResponse)(nil), // 24: olivetin.api.v1.ValidateArgumentTypeResponse - (*WatchExecutionRequest)(nil), // 25: olivetin.api.v1.WatchExecutionRequest - (*WatchExecutionUpdate)(nil), // 26: olivetin.api.v1.WatchExecutionUpdate - (*ExecutionStatusRequest)(nil), // 27: olivetin.api.v1.ExecutionStatusRequest - (*ExecutionStatusResponse)(nil), // 28: olivetin.api.v1.ExecutionStatusResponse - (*WhoAmIRequest)(nil), // 29: olivetin.api.v1.WhoAmIRequest - (*WhoAmIResponse)(nil), // 30: olivetin.api.v1.WhoAmIResponse - (*SosReportRequest)(nil), // 31: olivetin.api.v1.SosReportRequest - (*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse - (*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest - (*DumpVarsResponse)(nil), // 34: olivetin.api.v1.DumpVarsResponse - (*DebugBinding)(nil), // 35: olivetin.api.v1.DebugBinding - (*DumpPublicIdActionMapRequest)(nil), // 36: olivetin.api.v1.DumpPublicIdActionMapRequest - (*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse - (*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest - (*GetReadyzResponse)(nil), // 39: olivetin.api.v1.GetReadyzResponse - (*EventStreamRequest)(nil), // 40: olivetin.api.v1.EventStreamRequest - (*EventStreamResponse)(nil), // 41: olivetin.api.v1.EventStreamResponse - (*EventOutputChunk)(nil), // 42: olivetin.api.v1.EventOutputChunk - (*EventEntityChanged)(nil), // 43: olivetin.api.v1.EventEntityChanged - (*EventConfigChanged)(nil), // 44: olivetin.api.v1.EventConfigChanged - (*EventExecutionFinished)(nil), // 45: olivetin.api.v1.EventExecutionFinished - (*EventExecutionStarted)(nil), // 46: olivetin.api.v1.EventExecutionStarted - (*KillActionRequest)(nil), // 47: olivetin.api.v1.KillActionRequest - (*KillActionResponse)(nil), // 48: olivetin.api.v1.KillActionResponse - (*LocalUserLoginRequest)(nil), // 49: olivetin.api.v1.LocalUserLoginRequest - (*LocalUserLoginResponse)(nil), // 50: olivetin.api.v1.LocalUserLoginResponse - (*PasswordHashRequest)(nil), // 51: olivetin.api.v1.PasswordHashRequest - (*PasswordHashResponse)(nil), // 52: olivetin.api.v1.PasswordHashResponse - (*LogoutRequest)(nil), // 53: olivetin.api.v1.LogoutRequest - (*LogoutResponse)(nil), // 54: olivetin.api.v1.LogoutResponse - (*GetDiagnosticsRequest)(nil), // 55: olivetin.api.v1.GetDiagnosticsRequest - (*GetDiagnosticsResponse)(nil), // 56: olivetin.api.v1.GetDiagnosticsResponse - (*InitRequest)(nil), // 57: olivetin.api.v1.InitRequest - (*InitResponse)(nil), // 58: olivetin.api.v1.InitResponse - (*AdditionalLink)(nil), // 59: olivetin.api.v1.AdditionalLink - (*OAuth2Provider)(nil), // 60: olivetin.api.v1.OAuth2Provider - (*GetActionBindingRequest)(nil), // 61: olivetin.api.v1.GetActionBindingRequest - (*GetActionBindingResponse)(nil), // 62: olivetin.api.v1.GetActionBindingResponse - (*GetEntitiesRequest)(nil), // 63: olivetin.api.v1.GetEntitiesRequest - (*GetEntitiesResponse)(nil), // 64: olivetin.api.v1.GetEntitiesResponse - (*EntityDefinition)(nil), // 65: olivetin.api.v1.EntityDefinition - (*GetEntityRequest)(nil), // 66: olivetin.api.v1.GetEntityRequest - (*RestartActionRequest)(nil), // 67: olivetin.api.v1.RestartActionRequest - nil, // 68: olivetin.api.v1.ActionArgument.SuggestionsEntry - nil, // 69: olivetin.api.v1.Entity.FieldsEntry - nil, // 70: olivetin.api.v1.DumpVarsResponse.ContentsEntry - nil, // 71: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry + (*ActionGroupMembership)(nil), // 1: olivetin.api.v1.ActionGroupMembership + (*ActionWebhookExecHint)(nil), // 2: olivetin.api.v1.ActionWebhookExecHint + (*ActionArgument)(nil), // 3: olivetin.api.v1.ActionArgument + (*ActionArgumentChoice)(nil), // 4: olivetin.api.v1.ActionArgumentChoice + (*Entity)(nil), // 5: olivetin.api.v1.Entity + (*GetDashboardResponse)(nil), // 6: olivetin.api.v1.GetDashboardResponse + (*EffectivePolicy)(nil), // 7: olivetin.api.v1.EffectivePolicy + (*GetDashboardRequest)(nil), // 8: olivetin.api.v1.GetDashboardRequest + (*Dashboard)(nil), // 9: olivetin.api.v1.Dashboard + (*DashboardComponent)(nil), // 10: olivetin.api.v1.DashboardComponent + (*StartActionRequest)(nil), // 11: olivetin.api.v1.StartActionRequest + (*StartActionArgument)(nil), // 12: olivetin.api.v1.StartActionArgument + (*StartActionResponse)(nil), // 13: olivetin.api.v1.StartActionResponse + (*StartActionAndWaitRequest)(nil), // 14: olivetin.api.v1.StartActionAndWaitRequest + (*StartActionAndWaitResponse)(nil), // 15: olivetin.api.v1.StartActionAndWaitResponse + (*StartActionByGetRequest)(nil), // 16: olivetin.api.v1.StartActionByGetRequest + (*StartActionByGetResponse)(nil), // 17: olivetin.api.v1.StartActionByGetResponse + (*StartActionByGetAndWaitRequest)(nil), // 18: olivetin.api.v1.StartActionByGetAndWaitRequest + (*StartActionByGetAndWaitResponse)(nil), // 19: olivetin.api.v1.StartActionByGetAndWaitResponse + (*GetLogsRequest)(nil), // 20: olivetin.api.v1.GetLogsRequest + (*LogEntry)(nil), // 21: olivetin.api.v1.LogEntry + (*GetLogsResponse)(nil), // 22: olivetin.api.v1.GetLogsResponse + (*GetActionLogsRequest)(nil), // 23: olivetin.api.v1.GetActionLogsRequest + (*GetActionLogsResponse)(nil), // 24: olivetin.api.v1.GetActionLogsResponse + (*GetExecutionQueueRequest)(nil), // 25: olivetin.api.v1.GetExecutionQueueRequest + (*ExecutionQueueAction)(nil), // 26: olivetin.api.v1.ExecutionQueueAction + (*ExecutionQueueGroup)(nil), // 27: olivetin.api.v1.ExecutionQueueGroup + (*GetExecutionQueueResponse)(nil), // 28: olivetin.api.v1.GetExecutionQueueResponse + (*ValidateArgumentTypeRequest)(nil), // 29: olivetin.api.v1.ValidateArgumentTypeRequest + (*ValidateArgumentTypeResponse)(nil), // 30: olivetin.api.v1.ValidateArgumentTypeResponse + (*WatchExecutionRequest)(nil), // 31: olivetin.api.v1.WatchExecutionRequest + (*WatchExecutionUpdate)(nil), // 32: olivetin.api.v1.WatchExecutionUpdate + (*ExecutionStatusRequest)(nil), // 33: olivetin.api.v1.ExecutionStatusRequest + (*DashboardNavigationTarget)(nil), // 34: olivetin.api.v1.DashboardNavigationTarget + (*ExecutionStatusResponse)(nil), // 35: olivetin.api.v1.ExecutionStatusResponse + (*WhoAmIRequest)(nil), // 36: olivetin.api.v1.WhoAmIRequest + (*WhoAmIResponse)(nil), // 37: olivetin.api.v1.WhoAmIResponse + (*SosReportRequest)(nil), // 38: olivetin.api.v1.SosReportRequest + (*SosReportResponse)(nil), // 39: olivetin.api.v1.SosReportResponse + (*DumpVarsRequest)(nil), // 40: olivetin.api.v1.DumpVarsRequest + (*DumpVarsResponse)(nil), // 41: olivetin.api.v1.DumpVarsResponse + (*DebugBinding)(nil), // 42: olivetin.api.v1.DebugBinding + (*DumpPublicIdActionMapRequest)(nil), // 43: olivetin.api.v1.DumpPublicIdActionMapRequest + (*DumpPublicIdActionMapResponse)(nil), // 44: olivetin.api.v1.DumpPublicIdActionMapResponse + (*GetReadyzRequest)(nil), // 45: olivetin.api.v1.GetReadyzRequest + (*GetReadyzResponse)(nil), // 46: olivetin.api.v1.GetReadyzResponse + (*EventStreamRequest)(nil), // 47: olivetin.api.v1.EventStreamRequest + (*EventStreamResponse)(nil), // 48: olivetin.api.v1.EventStreamResponse + (*EventOutputChunk)(nil), // 49: olivetin.api.v1.EventOutputChunk + (*EventEntityChanged)(nil), // 50: olivetin.api.v1.EventEntityChanged + (*EventConfigChanged)(nil), // 51: olivetin.api.v1.EventConfigChanged + (*EventHeartbeat)(nil), // 52: olivetin.api.v1.EventHeartbeat + (*EventExecutionFinished)(nil), // 53: olivetin.api.v1.EventExecutionFinished + (*EventExecutionStarted)(nil), // 54: olivetin.api.v1.EventExecutionStarted + (*KillActionRequest)(nil), // 55: olivetin.api.v1.KillActionRequest + (*KillActionResponse)(nil), // 56: olivetin.api.v1.KillActionResponse + (*LocalUserLoginRequest)(nil), // 57: olivetin.api.v1.LocalUserLoginRequest + (*LocalUserLoginResponse)(nil), // 58: olivetin.api.v1.LocalUserLoginResponse + (*PasswordHashRequest)(nil), // 59: olivetin.api.v1.PasswordHashRequest + (*PasswordHashResponse)(nil), // 60: olivetin.api.v1.PasswordHashResponse + (*LogoutRequest)(nil), // 61: olivetin.api.v1.LogoutRequest + (*LogoutResponse)(nil), // 62: olivetin.api.v1.LogoutResponse + (*GetDiagnosticsRequest)(nil), // 63: olivetin.api.v1.GetDiagnosticsRequest + (*GetDiagnosticsResponse)(nil), // 64: olivetin.api.v1.GetDiagnosticsResponse + (*InitRequest)(nil), // 65: olivetin.api.v1.InitRequest + (*InitResponse)(nil), // 66: olivetin.api.v1.InitResponse + (*AdditionalLink)(nil), // 67: olivetin.api.v1.AdditionalLink + (*OAuth2Provider)(nil), // 68: olivetin.api.v1.OAuth2Provider + (*GetActionBindingRequest)(nil), // 69: olivetin.api.v1.GetActionBindingRequest + (*GetActionBindingResponse)(nil), // 70: olivetin.api.v1.GetActionBindingResponse + (*GetEntitiesRequest)(nil), // 71: olivetin.api.v1.GetEntitiesRequest + (*GetEntitiesResponse)(nil), // 72: olivetin.api.v1.GetEntitiesResponse + (*EntityDefinition)(nil), // 73: olivetin.api.v1.EntityDefinition + (*GetEntityRequest)(nil), // 74: olivetin.api.v1.GetEntityRequest + (*RestartActionRequest)(nil), // 75: olivetin.api.v1.RestartActionRequest + nil, // 76: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry + nil, // 77: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry + nil, // 78: olivetin.api.v1.ActionArgument.SuggestionsEntry + nil, // 79: olivetin.api.v1.Entity.FieldsEntry + nil, // 80: olivetin.api.v1.DumpVarsResponse.ContentsEntry + nil, // 81: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry } var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{ - 1, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument - 2, // 1: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice - 68, // 2: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry - 69, // 3: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry - 7, // 4: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard - 8, // 5: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent - 8, // 6: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent - 0, // 7: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action - 10, // 8: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument - 10, // 9: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument - 19, // 10: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 11: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 12: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry - 19, // 13: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry - 19, // 14: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 70, // 15: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry - 71, // 16: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry - 43, // 17: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged - 44, // 18: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged - 45, // 19: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished - 46, // 20: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted - 42, // 21: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk - 19, // 22: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 23: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry - 60, // 24: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider - 59, // 25: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink - 5, // 26: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy - 0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action - 65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition - 3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity - 35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding - 6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest - 9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest - 12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest - 14, // 34: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest - 16, // 35: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest - 67, // 36: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest - 47, // 37: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest - 27, // 38: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest - 18, // 39: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest - 21, // 40: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest - 23, // 41: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest - 29, // 42: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest - 31, // 43: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest - 33, // 44: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest - 36, // 45: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest - 38, // 46: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest - 49, // 47: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest - 51, // 48: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest - 53, // 49: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest - 40, // 50: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest - 55, // 51: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest - 57, // 52: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest - 61, // 53: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest - 63, // 54: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest - 66, // 55: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest - 4, // 56: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse - 11, // 57: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse - 13, // 58: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse - 15, // 59: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse - 17, // 60: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse - 11, // 61: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse - 48, // 62: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse - 28, // 63: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse - 20, // 64: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse - 22, // 65: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse - 24, // 66: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse - 30, // 67: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse - 32, // 68: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse - 34, // 69: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse - 37, // 70: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse - 39, // 71: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse - 50, // 72: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse - 52, // 73: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse - 54, // 74: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse - 41, // 75: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse - 56, // 76: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse - 58, // 77: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse - 62, // 78: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse - 64, // 79: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse - 3, // 80: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity - 56, // [56:81] is the sub-list for method output_type - 31, // [31:56] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name + 3, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument + 2, // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint + 1, // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership + 76, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry + 77, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry + 4, // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice + 78, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry + 79, // 7: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry + 9, // 8: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard + 10, // 9: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent + 10, // 10: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent + 0, // 11: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action + 12, // 12: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument + 12, // 13: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument + 21, // 14: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry + 21, // 15: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry + 21, // 16: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry + 21, // 17: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry + 21, // 18: olivetin.api.v1.ExecutionQueueAction.entries:type_name -> olivetin.api.v1.LogEntry + 26, // 19: olivetin.api.v1.ExecutionQueueGroup.actions:type_name -> olivetin.api.v1.ExecutionQueueAction + 27, // 20: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup + 21, // 21: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry + 34, // 22: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget + 80, // 23: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry + 81, // 24: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry + 50, // 25: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged + 51, // 26: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged + 53, // 27: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished + 54, // 28: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted + 49, // 29: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk + 52, // 30: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat + 21, // 31: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry + 21, // 32: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry + 68, // 33: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider + 67, // 34: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink + 7, // 35: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy + 0, // 36: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action + 34, // 37: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget + 73, // 38: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition + 5, // 39: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity + 42, // 40: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding + 8, // 41: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest + 11, // 42: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest + 14, // 43: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest + 16, // 44: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest + 18, // 45: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest + 75, // 46: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest + 55, // 47: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest + 33, // 48: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest + 20, // 49: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest + 23, // 50: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest + 25, // 51: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest + 29, // 52: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest + 36, // 53: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest + 38, // 54: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest + 40, // 55: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest + 43, // 56: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest + 45, // 57: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest + 57, // 58: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest + 59, // 59: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest + 61, // 60: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest + 47, // 61: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest + 63, // 62: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest + 65, // 63: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest + 69, // 64: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest + 71, // 65: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest + 74, // 66: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest + 6, // 67: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse + 13, // 68: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse + 15, // 69: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse + 17, // 70: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse + 19, // 71: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse + 13, // 72: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse + 56, // 73: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse + 35, // 74: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse + 22, // 75: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse + 24, // 76: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse + 28, // 77: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse + 30, // 78: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse + 37, // 79: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse + 39, // 80: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse + 41, // 81: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse + 44, // 82: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse + 46, // 83: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse + 58, // 84: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse + 60, // 85: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse + 62, // 86: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse + 48, // 87: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse + 64, // 88: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse + 66, // 89: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse + 70, // 90: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse + 72, // 91: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse + 5, // 92: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity + 67, // [67:93] is the sub-list for method output_type + 41, // [41:67] is the sub-list for method input_type + 41, // [41:41] is the sub-list for extension type_name + 41, // [41:41] is the sub-list for extension extendee + 0, // [0:41] is the sub-list for field type_name } func init() { file_olivetin_api_v1_olivetin_proto_init() } @@ -4369,12 +5144,13 @@ func file_olivetin_api_v1_olivetin_proto_init() { if File_olivetin_api_v1_olivetin_proto != nil { return } - file_olivetin_api_v1_olivetin_proto_msgTypes[41].OneofWrappers = []any{ + file_olivetin_api_v1_olivetin_proto_msgTypes[48].OneofWrappers = []any{ (*EventStreamResponse_EntityChanged)(nil), (*EventStreamResponse_ConfigChanged)(nil), (*EventStreamResponse_ExecutionFinished)(nil), (*EventStreamResponse_ExecutionStarted)(nil), (*EventStreamResponse_OutputChunk)(nil), + (*EventStreamResponse_Heartbeat)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -4382,7 +5158,7 @@ func file_olivetin_api_v1_olivetin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)), NumEnums: 0, - NumMessages: 72, + NumMessages: 82, NumExtensions: 0, NumServices: 1, }, diff --git a/service/gen/olivetin/api/v1/olivetin.pb.go.orig b/service/gen/olivetin/api/v1/olivetin.pb.go.orig deleted file mode 100644 index 74592d6..0000000 --- a/service/gen/olivetin/api/v1/olivetin.pb.go.orig +++ /dev/null @@ -1,4378 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: olivetin/api/v1/olivetin.proto - -package apiv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -type Action struct { - state protoimpl.MessageState `protogen:"open.v1"` - BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"` - CanExec bool `protobuf:"varint,4,opt,name=can_exec,json=canExec,proto3" json:"can_exec,omitempty"` - Arguments []*ActionArgument `protobuf:"bytes,5,rep,name=arguments,proto3" json:"arguments,omitempty"` - PopupOnStart string `protobuf:"bytes,6,opt,name=popup_on_start,json=popupOnStart,proto3" json:"popup_on_start,omitempty"` - Order int32 `protobuf:"varint,7,opt,name=order,proto3" json:"order,omitempty"` - Timeout int32 `protobuf:"varint,8,opt,name=timeout,proto3" json:"timeout,omitempty"` - DatetimeRateLimitExpires string `protobuf:"bytes,9,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Action) Reset() { - *x = Action{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Action) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Action) ProtoMessage() {} - -func (x *Action) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Action.ProtoReflect.Descriptor instead. -func (*Action) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{0} -} - -func (x *Action) GetBindingId() string { - if x != nil { - return x.BindingId - } - return "" -} - -func (x *Action) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *Action) GetIcon() string { - if x != nil { - return x.Icon - } - return "" -} - -func (x *Action) GetCanExec() bool { - if x != nil { - return x.CanExec - } - return false -} - -func (x *Action) GetArguments() []*ActionArgument { - if x != nil { - return x.Arguments - } - return nil -} - -func (x *Action) GetPopupOnStart() string { - if x != nil { - return x.PopupOnStart - } - return "" -} - -func (x *Action) GetOrder() int32 { - if x != nil { - return x.Order - } - return 0 -} - -func (x *Action) GetTimeout() int32 { - if x != nil { - return x.Timeout - } - return 0 -} - -func (x *Action) GetDatetimeRateLimitExpires() string { - if x != nil { - return x.DatetimeRateLimitExpires - } - return "" -} - -type ActionArgument struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` - DefaultValue string `protobuf:"bytes,4,opt,name=default_value,json=defaultValue,proto3" json:"default_value,omitempty"` - Choices []*ActionArgumentChoice `protobuf:"bytes,5,rep,name=choices,proto3" json:"choices,omitempty"` - Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"` - Suggestions map[string]string `protobuf:"bytes,7,rep,name=suggestions,proto3" json:"suggestions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - SuggestionsBrowserKey string `protobuf:"bytes,8,opt,name=suggestions_browser_key,json=suggestionsBrowserKey,proto3" json:"suggestions_browser_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ActionArgument) Reset() { - *x = ActionArgument{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ActionArgument) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ActionArgument) ProtoMessage() {} - -func (x *ActionArgument) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ActionArgument.ProtoReflect.Descriptor instead. -func (*ActionArgument) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{1} -} - -func (x *ActionArgument) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *ActionArgument) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *ActionArgument) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *ActionArgument) GetDefaultValue() string { - if x != nil { - return x.DefaultValue - } - return "" -} - -func (x *ActionArgument) GetChoices() []*ActionArgumentChoice { - if x != nil { - return x.Choices - } - return nil -} - -func (x *ActionArgument) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -func (x *ActionArgument) GetSuggestions() map[string]string { - if x != nil { - return x.Suggestions - } - return nil -} - -func (x *ActionArgument) GetSuggestionsBrowserKey() string { - if x != nil { - return x.SuggestionsBrowserKey - } - return "" -} - -type ActionArgumentChoice struct { - state protoimpl.MessageState `protogen:"open.v1"` - Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` - Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ActionArgumentChoice) Reset() { - *x = ActionArgumentChoice{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ActionArgumentChoice) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ActionArgumentChoice) ProtoMessage() {} - -func (x *ActionArgumentChoice) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ActionArgumentChoice.ProtoReflect.Descriptor instead. -func (*ActionArgumentChoice) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{2} -} - -func (x *ActionArgumentChoice) GetValue() string { - if x != nil { - return x.Value - } - return "" -} - -func (x *ActionArgumentChoice) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -type Entity struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - UniqueKey string `protobuf:"bytes,2,opt,name=unique_key,json=uniqueKey,proto3" json:"unique_key,omitempty"` - Type string `protobuf:"bytes,3,opt,name=type,proto3" json:"type,omitempty"` - Directories []string `protobuf:"bytes,4,rep,name=directories,proto3" json:"directories,omitempty"` - Fields map[string]string `protobuf:"bytes,5,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Entity) Reset() { - *x = Entity{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Entity) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Entity) ProtoMessage() {} - -func (x *Entity) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Entity.ProtoReflect.Descriptor instead. -func (*Entity) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{3} -} - -func (x *Entity) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *Entity) GetUniqueKey() string { - if x != nil { - return x.UniqueKey - } - return "" -} - -func (x *Entity) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *Entity) GetDirectories() []string { - if x != nil { - return x.Directories - } - return nil -} - -func (x *Entity) GetFields() map[string]string { - if x != nil { - return x.Fields - } - return nil -} - -type GetDashboardResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Dashboard *Dashboard `protobuf:"bytes,4,opt,name=dashboard,proto3" json:"dashboard,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDashboardResponse) Reset() { - *x = GetDashboardResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDashboardResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDashboardResponse) ProtoMessage() {} - -func (x *GetDashboardResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDashboardResponse.ProtoReflect.Descriptor instead. -func (*GetDashboardResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{4} -} - -func (x *GetDashboardResponse) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *GetDashboardResponse) GetDashboard() *Dashboard { - if x != nil { - return x.Dashboard - } - return nil -} - -type EffectivePolicy struct { - state protoimpl.MessageState `protogen:"open.v1"` - ShowDiagnostics bool `protobuf:"varint,1,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"` - ShowLogList bool `protobuf:"varint,2,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EffectivePolicy) Reset() { - *x = EffectivePolicy{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EffectivePolicy) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EffectivePolicy) ProtoMessage() {} - -func (x *EffectivePolicy) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EffectivePolicy.ProtoReflect.Descriptor instead. -func (*EffectivePolicy) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{5} -} - -func (x *EffectivePolicy) GetShowDiagnostics() bool { - if x != nil { - return x.ShowDiagnostics - } - return false -} - -func (x *EffectivePolicy) GetShowLogList() bool { - if x != nil { - return x.ShowLogList - } - return false -} - -type GetDashboardRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - EntityType string `protobuf:"bytes,2,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` - EntityKey string `protobuf:"bytes,3,opt,name=entity_key,json=entityKey,proto3" json:"entity_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDashboardRequest) Reset() { - *x = GetDashboardRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDashboardRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDashboardRequest) ProtoMessage() {} - -func (x *GetDashboardRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDashboardRequest.ProtoReflect.Descriptor instead. -func (*GetDashboardRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{6} -} - -func (x *GetDashboardRequest) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *GetDashboardRequest) GetEntityType() string { - if x != nil { - return x.EntityType - } - return "" -} - -func (x *GetDashboardRequest) GetEntityKey() string { - if x != nil { - return x.EntityKey - } - return "" -} - -type Dashboard struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Contents []*DashboardComponent `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Dashboard) Reset() { - *x = Dashboard{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Dashboard) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Dashboard) ProtoMessage() {} - -func (x *Dashboard) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[7] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Dashboard.ProtoReflect.Descriptor instead. -func (*Dashboard) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{7} -} - -func (x *Dashboard) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *Dashboard) GetContents() []*DashboardComponent { - if x != nil { - return x.Contents - } - return nil -} - -type DashboardComponent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - Contents []*DashboardComponent `protobuf:"bytes,3,rep,name=contents,proto3" json:"contents,omitempty"` - Icon string `protobuf:"bytes,4,opt,name=icon,proto3" json:"icon,omitempty"` - CssClass string `protobuf:"bytes,5,opt,name=css_class,json=cssClass,proto3" json:"css_class,omitempty"` - Action *Action `protobuf:"bytes,6,opt,name=action,proto3" json:"action,omitempty"` - EntityType string `protobuf:"bytes,7,opt,name=entity_type,json=entityType,proto3" json:"entity_type,omitempty"` - EntityKey string `protobuf:"bytes,8,opt,name=entity_key,json=entityKey,proto3" json:"entity_key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DashboardComponent) Reset() { - *x = DashboardComponent{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DashboardComponent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DashboardComponent) ProtoMessage() {} - -func (x *DashboardComponent) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[8] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DashboardComponent.ProtoReflect.Descriptor instead. -func (*DashboardComponent) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{8} -} - -func (x *DashboardComponent) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *DashboardComponent) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *DashboardComponent) GetContents() []*DashboardComponent { - if x != nil { - return x.Contents - } - return nil -} - -func (x *DashboardComponent) GetIcon() string { - if x != nil { - return x.Icon - } - return "" -} - -func (x *DashboardComponent) GetCssClass() string { - if x != nil { - return x.CssClass - } - return "" -} - -func (x *DashboardComponent) GetAction() *Action { - if x != nil { - return x.Action - } - return nil -} - -func (x *DashboardComponent) GetEntityType() string { - if x != nil { - return x.EntityType - } - return "" -} - -func (x *DashboardComponent) GetEntityKey() string { - if x != nil { - return x.EntityKey - } - return "" -} - -type StartActionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` - Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"` - UniqueTrackingId string `protobuf:"bytes,3,opt,name=unique_tracking_id,json=uniqueTrackingId,proto3" json:"unique_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionRequest) Reset() { - *x = StartActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionRequest) ProtoMessage() {} - -func (x *StartActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[9] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionRequest.ProtoReflect.Descriptor instead. -func (*StartActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{9} -} - -func (x *StartActionRequest) GetBindingId() string { - if x != nil { - return x.BindingId - } - return "" -} - -func (x *StartActionRequest) GetArguments() []*StartActionArgument { - if x != nil { - return x.Arguments - } - return nil -} - -func (x *StartActionRequest) GetUniqueTrackingId() string { - if x != nil { - return x.UniqueTrackingId - } - return "" -} - -type StartActionArgument struct { - state protoimpl.MessageState `protogen:"open.v1"` - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionArgument) Reset() { - *x = StartActionArgument{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionArgument) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionArgument) ProtoMessage() {} - -func (x *StartActionArgument) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[10] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionArgument.ProtoReflect.Descriptor instead. -func (*StartActionArgument) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{10} -} - -func (x *StartActionArgument) GetName() string { - if x != nil { - return x.Name - } - return "" -} - -func (x *StartActionArgument) GetValue() string { - if x != nil { - return x.Value - } - return "" -} - -type StartActionResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,2,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionResponse) Reset() { - *x = StartActionResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionResponse) ProtoMessage() {} - -func (x *StartActionResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[11] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionResponse.ProtoReflect.Descriptor instead. -func (*StartActionResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{11} -} - -func (x *StartActionResponse) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -type StartActionAndWaitRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` - Arguments []*StartActionArgument `protobuf:"bytes,2,rep,name=arguments,proto3" json:"arguments,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionAndWaitRequest) Reset() { - *x = StartActionAndWaitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionAndWaitRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionAndWaitRequest) ProtoMessage() {} - -func (x *StartActionAndWaitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[12] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionAndWaitRequest.ProtoReflect.Descriptor instead. -func (*StartActionAndWaitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{12} -} - -func (x *StartActionAndWaitRequest) GetActionId() string { - if x != nil { - return x.ActionId - } - return "" -} - -func (x *StartActionAndWaitRequest) GetArguments() []*StartActionArgument { - if x != nil { - return x.Arguments - } - return nil -} - -type StartActionAndWaitResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionAndWaitResponse) Reset() { - *x = StartActionAndWaitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionAndWaitResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionAndWaitResponse) ProtoMessage() {} - -func (x *StartActionAndWaitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[13] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionAndWaitResponse.ProtoReflect.Descriptor instead. -func (*StartActionAndWaitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{13} -} - -func (x *StartActionAndWaitResponse) GetLogEntry() *LogEntry { - if x != nil { - return x.LogEntry - } - return nil -} - -type StartActionByGetRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionByGetRequest) Reset() { - *x = StartActionByGetRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionByGetRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionByGetRequest) ProtoMessage() {} - -func (x *StartActionByGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[14] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionByGetRequest.ProtoReflect.Descriptor instead. -func (*StartActionByGetRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{14} -} - -func (x *StartActionByGetRequest) GetActionId() string { - if x != nil { - return x.ActionId - } - return "" -} - -type StartActionByGetResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,2,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionByGetResponse) Reset() { - *x = StartActionByGetResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionByGetResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionByGetResponse) ProtoMessage() {} - -func (x *StartActionByGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[15] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionByGetResponse.ProtoReflect.Descriptor instead. -func (*StartActionByGetResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{15} -} - -func (x *StartActionByGetResponse) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -type StartActionByGetAndWaitRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionByGetAndWaitRequest) Reset() { - *x = StartActionByGetAndWaitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionByGetAndWaitRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionByGetAndWaitRequest) ProtoMessage() {} - -func (x *StartActionByGetAndWaitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[16] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionByGetAndWaitRequest.ProtoReflect.Descriptor instead. -func (*StartActionByGetAndWaitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{16} -} - -func (x *StartActionByGetAndWaitRequest) GetActionId() string { - if x != nil { - return x.ActionId - } - return "" -} - -type StartActionByGetAndWaitResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartActionByGetAndWaitResponse) Reset() { - *x = StartActionByGetAndWaitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartActionByGetAndWaitResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartActionByGetAndWaitResponse) ProtoMessage() {} - -func (x *StartActionByGetAndWaitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[17] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use StartActionByGetAndWaitResponse.ProtoReflect.Descriptor instead. -func (*StartActionByGetAndWaitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{17} -} - -func (x *StartActionByGetAndWaitResponse) GetLogEntry() *LogEntry { - if x != nil { - return x.LogEntry - } - return nil -} - -type GetLogsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - StartOffset int64 `protobuf:"varint,1,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"` - DateFilter string `protobuf:"bytes,2,opt,name=date_filter,json=dateFilter,proto3" json:"date_filter,omitempty"` // Optional date filter in YYYY-MM-DD format - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetLogsRequest) Reset() { - *x = GetLogsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetLogsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetLogsRequest) ProtoMessage() {} - -func (x *GetLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[18] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetLogsRequest.ProtoReflect.Descriptor instead. -func (*GetLogsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{18} -} - -func (x *GetLogsRequest) GetStartOffset() int64 { - if x != nil { - return x.StartOffset - } - return 0 -} - -func (x *GetLogsRequest) GetDateFilter() string { - if x != nil { - return x.DateFilter - } - return "" -} - -type LogEntry struct { - state protoimpl.MessageState `protogen:"open.v1"` - DatetimeStarted string `protobuf:"bytes,1,opt,name=datetime_started,json=datetimeStarted,proto3" json:"datetime_started,omitempty"` - ActionTitle string `protobuf:"bytes,2,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"` - Output string `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"` - TimedOut bool `protobuf:"varint,5,opt,name=timed_out,json=timedOut,proto3" json:"timed_out,omitempty"` - ExitCode int32 `protobuf:"varint,6,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` - User string `protobuf:"bytes,7,opt,name=user,proto3" json:"user,omitempty"` - UserClass string `protobuf:"bytes,8,opt,name=user_class,json=userClass,proto3" json:"user_class,omitempty"` - ActionIcon string `protobuf:"bytes,9,opt,name=action_icon,json=actionIcon,proto3" json:"action_icon,omitempty"` - Tags []string `protobuf:"bytes,10,rep,name=tags,proto3" json:"tags,omitempty"` - ExecutionTrackingId string `protobuf:"bytes,11,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - DatetimeFinished string `protobuf:"bytes,12,opt,name=datetime_finished,json=datetimeFinished,proto3" json:"datetime_finished,omitempty"` - ExecutionStarted bool `protobuf:"varint,14,opt,name=execution_started,json=executionStarted,proto3" json:"execution_started,omitempty"` - ExecutionFinished bool `protobuf:"varint,15,opt,name=execution_finished,json=executionFinished,proto3" json:"execution_finished,omitempty"` - Blocked bool `protobuf:"varint,16,opt,name=blocked,proto3" json:"blocked,omitempty"` - DatetimeIndex int64 `protobuf:"varint,17,opt,name=datetime_index,json=datetimeIndex,proto3" json:"datetime_index,omitempty"` - CanKill bool `protobuf:"varint,18,opt,name=can_kill,json=canKill,proto3" json:"can_kill,omitempty"` - DatetimeRateLimitExpires string `protobuf:"bytes,19,opt,name=datetime_rate_limit_expires,json=datetimeRateLimitExpires,proto3" json:"datetime_rate_limit_expires,omitempty"` // Datetime when rate limit expires (empty string if not rate limited), format: "2006-01-02 15:04:05" - BindingId string `protobuf:"bytes,20,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` // Binding ID for matching rate limits to action buttons - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LogEntry) Reset() { - *x = LogEntry{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LogEntry) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LogEntry) ProtoMessage() {} - -func (x *LogEntry) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[19] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LogEntry.ProtoReflect.Descriptor instead. -func (*LogEntry) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{19} -} - -func (x *LogEntry) GetDatetimeStarted() string { - if x != nil { - return x.DatetimeStarted - } - return "" -} - -func (x *LogEntry) GetActionTitle() string { - if x != nil { - return x.ActionTitle - } - return "" -} - -func (x *LogEntry) GetOutput() string { - if x != nil { - return x.Output - } - return "" -} - -func (x *LogEntry) GetTimedOut() bool { - if x != nil { - return x.TimedOut - } - return false -} - -func (x *LogEntry) GetExitCode() int32 { - if x != nil { - return x.ExitCode - } - return 0 -} - -func (x *LogEntry) GetUser() string { - if x != nil { - return x.User - } - return "" -} - -func (x *LogEntry) GetUserClass() string { - if x != nil { - return x.UserClass - } - return "" -} - -func (x *LogEntry) GetActionIcon() string { - if x != nil { - return x.ActionIcon - } - return "" -} - -func (x *LogEntry) GetTags() []string { - if x != nil { - return x.Tags - } - return nil -} - -func (x *LogEntry) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -func (x *LogEntry) GetDatetimeFinished() string { - if x != nil { - return x.DatetimeFinished - } - return "" -} - -func (x *LogEntry) GetExecutionStarted() bool { - if x != nil { - return x.ExecutionStarted - } - return false -} - -func (x *LogEntry) GetExecutionFinished() bool { - if x != nil { - return x.ExecutionFinished - } - return false -} - -func (x *LogEntry) GetBlocked() bool { - if x != nil { - return x.Blocked - } - return false -} - -func (x *LogEntry) GetDatetimeIndex() int64 { - if x != nil { - return x.DatetimeIndex - } - return 0 -} - -func (x *LogEntry) GetCanKill() bool { - if x != nil { - return x.CanKill - } - return false -} - -func (x *LogEntry) GetDatetimeRateLimitExpires() string { - if x != nil { - return x.DatetimeRateLimitExpires - } - return "" -} - -func (x *LogEntry) GetBindingId() string { - if x != nil { - return x.BindingId - } - return "" -} - -type GetLogsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` - CountRemaining int64 `protobuf:"varint,2,opt,name=count_remaining,json=countRemaining,proto3" json:"count_remaining,omitempty"` - PageSize int64 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` - TotalCount int64 `protobuf:"varint,4,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` - StartOffset int64 `protobuf:"varint,5,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetLogsResponse) Reset() { - *x = GetLogsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetLogsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetLogsResponse) ProtoMessage() {} - -func (x *GetLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[20] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetLogsResponse.ProtoReflect.Descriptor instead. -func (*GetLogsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{20} -} - -func (x *GetLogsResponse) GetLogs() []*LogEntry { - if x != nil { - return x.Logs - } - return nil -} - -func (x *GetLogsResponse) GetCountRemaining() int64 { - if x != nil { - return x.CountRemaining - } - return 0 -} - -func (x *GetLogsResponse) GetPageSize() int64 { - if x != nil { - return x.PageSize - } - return 0 -} - -func (x *GetLogsResponse) GetTotalCount() int64 { - if x != nil { - return x.TotalCount - } - return 0 -} - -func (x *GetLogsResponse) GetStartOffset() int64 { - if x != nil { - return x.StartOffset - } - return 0 -} - -type GetActionLogsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionId string `protobuf:"bytes,1,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` - StartOffset int64 `protobuf:"varint,2,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetActionLogsRequest) Reset() { - *x = GetActionLogsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetActionLogsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetActionLogsRequest) ProtoMessage() {} - -func (x *GetActionLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetActionLogsRequest.ProtoReflect.Descriptor instead. -func (*GetActionLogsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{21} -} - -func (x *GetActionLogsRequest) GetActionId() string { - if x != nil { - return x.ActionId - } - return "" -} - -func (x *GetActionLogsRequest) GetStartOffset() int64 { - if x != nil { - return x.StartOffset - } - return 0 -} - -type GetActionLogsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Logs []*LogEntry `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` - CountRemaining int64 `protobuf:"varint,2,opt,name=count_remaining,json=countRemaining,proto3" json:"count_remaining,omitempty"` - PageSize int64 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` - TotalCount int64 `protobuf:"varint,4,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` - StartOffset int64 `protobuf:"varint,5,opt,name=start_offset,json=startOffset,proto3" json:"start_offset,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetActionLogsResponse) Reset() { - *x = GetActionLogsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetActionLogsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetActionLogsResponse) ProtoMessage() {} - -func (x *GetActionLogsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[22] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetActionLogsResponse.ProtoReflect.Descriptor instead. -func (*GetActionLogsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{22} -} - -func (x *GetActionLogsResponse) GetLogs() []*LogEntry { - if x != nil { - return x.Logs - } - return nil -} - -func (x *GetActionLogsResponse) GetCountRemaining() int64 { - if x != nil { - return x.CountRemaining - } - return 0 -} - -func (x *GetActionLogsResponse) GetPageSize() int64 { - if x != nil { - return x.PageSize - } - return 0 -} - -func (x *GetActionLogsResponse) GetTotalCount() int64 { - if x != nil { - return x.TotalCount - } - return 0 -} - -func (x *GetActionLogsResponse) GetStartOffset() int64 { - if x != nil { - return x.StartOffset - } - return 0 -} - -type ValidateArgumentTypeRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - BindingId string `protobuf:"bytes,3,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` - ArgumentName string `protobuf:"bytes,4,opt,name=argument_name,json=argumentName,proto3" json:"argument_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ValidateArgumentTypeRequest) Reset() { - *x = ValidateArgumentTypeRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ValidateArgumentTypeRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ValidateArgumentTypeRequest) ProtoMessage() {} - -func (x *ValidateArgumentTypeRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[23] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ValidateArgumentTypeRequest.ProtoReflect.Descriptor instead. -func (*ValidateArgumentTypeRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{23} -} - -func (x *ValidateArgumentTypeRequest) GetValue() string { - if x != nil { - return x.Value - } - return "" -} - -func (x *ValidateArgumentTypeRequest) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -func (x *ValidateArgumentTypeRequest) GetBindingId() string { - if x != nil { - return x.BindingId - } - return "" -} - -func (x *ValidateArgumentTypeRequest) GetArgumentName() string { - if x != nil { - return x.ArgumentName - } - return "" -} - -type ValidateArgumentTypeResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` - Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ValidateArgumentTypeResponse) Reset() { - *x = ValidateArgumentTypeResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ValidateArgumentTypeResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ValidateArgumentTypeResponse) ProtoMessage() {} - -func (x *ValidateArgumentTypeResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[24] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ValidateArgumentTypeResponse.ProtoReflect.Descriptor instead. -func (*ValidateArgumentTypeResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{24} -} - -func (x *ValidateArgumentTypeResponse) GetValid() bool { - if x != nil { - return x.Valid - } - return false -} - -func (x *ValidateArgumentTypeResponse) GetDescription() string { - if x != nil { - return x.Description - } - return "" -} - -type WatchExecutionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WatchExecutionRequest) Reset() { - *x = WatchExecutionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WatchExecutionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WatchExecutionRequest) ProtoMessage() {} - -func (x *WatchExecutionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[25] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WatchExecutionRequest.ProtoReflect.Descriptor instead. -func (*WatchExecutionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{25} -} - -func (x *WatchExecutionRequest) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -type WatchExecutionUpdate struct { - state protoimpl.MessageState `protogen:"open.v1"` - Update string `protobuf:"bytes,1,opt,name=update,proto3" json:"update,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WatchExecutionUpdate) Reset() { - *x = WatchExecutionUpdate{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WatchExecutionUpdate) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WatchExecutionUpdate) ProtoMessage() {} - -func (x *WatchExecutionUpdate) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[26] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WatchExecutionUpdate.ProtoReflect.Descriptor instead. -func (*WatchExecutionUpdate) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{26} -} - -func (x *WatchExecutionUpdate) GetUpdate() string { - if x != nil { - return x.Update - } - return "" -} - -type ExecutionStatusRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - ActionId string `protobuf:"bytes,2,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExecutionStatusRequest) Reset() { - *x = ExecutionStatusRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExecutionStatusRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecutionStatusRequest) ProtoMessage() {} - -func (x *ExecutionStatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[27] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecutionStatusRequest.ProtoReflect.Descriptor instead. -func (*ExecutionStatusRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{27} -} - -func (x *ExecutionStatusRequest) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -func (x *ExecutionStatusRequest) GetActionId() string { - if x != nil { - return x.ActionId - } - return "" -} - -type ExecutionStatusResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExecutionStatusResponse) Reset() { - *x = ExecutionStatusResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExecutionStatusResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExecutionStatusResponse) ProtoMessage() {} - -func (x *ExecutionStatusResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[28] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExecutionStatusResponse.ProtoReflect.Descriptor instead. -func (*ExecutionStatusResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{28} -} - -func (x *ExecutionStatusResponse) GetLogEntry() *LogEntry { - if x != nil { - return x.LogEntry - } - return nil -} - -type WhoAmIRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WhoAmIRequest) Reset() { - *x = WhoAmIRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WhoAmIRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WhoAmIRequest) ProtoMessage() {} - -func (x *WhoAmIRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[29] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WhoAmIRequest.ProtoReflect.Descriptor instead. -func (*WhoAmIRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{29} -} - -type WhoAmIResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - AuthenticatedUser string `protobuf:"bytes,1,opt,name=authenticated_user,json=authenticatedUser,proto3" json:"authenticated_user,omitempty"` - Usergroup string `protobuf:"bytes,2,opt,name=usergroup,proto3" json:"usergroup,omitempty"` - Provider string `protobuf:"bytes,3,opt,name=provider,proto3" json:"provider,omitempty"` - Acls []string `protobuf:"bytes,4,rep,name=acls,proto3" json:"acls,omitempty"` - Sid string `protobuf:"bytes,5,opt,name=sid,proto3" json:"sid,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *WhoAmIResponse) Reset() { - *x = WhoAmIResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *WhoAmIResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*WhoAmIResponse) ProtoMessage() {} - -func (x *WhoAmIResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[30] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use WhoAmIResponse.ProtoReflect.Descriptor instead. -func (*WhoAmIResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{30} -} - -func (x *WhoAmIResponse) GetAuthenticatedUser() string { - if x != nil { - return x.AuthenticatedUser - } - return "" -} - -func (x *WhoAmIResponse) GetUsergroup() string { - if x != nil { - return x.Usergroup - } - return "" -} - -func (x *WhoAmIResponse) GetProvider() string { - if x != nil { - return x.Provider - } - return "" -} - -func (x *WhoAmIResponse) GetAcls() []string { - if x != nil { - return x.Acls - } - return nil -} - -func (x *WhoAmIResponse) GetSid() string { - if x != nil { - return x.Sid - } - return "" -} - -type SosReportRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SosReportRequest) Reset() { - *x = SosReportRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SosReportRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SosReportRequest) ProtoMessage() {} - -func (x *SosReportRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[31] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SosReportRequest.ProtoReflect.Descriptor instead. -func (*SosReportRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{31} -} - -type SosReportResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *SosReportResponse) Reset() { - *x = SosReportResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *SosReportResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*SosReportResponse) ProtoMessage() {} - -func (x *SosReportResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[32] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use SosReportResponse.ProtoReflect.Descriptor instead. -func (*SosReportResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{32} -} - -func (x *SosReportResponse) GetAlert() string { - if x != nil { - return x.Alert - } - return "" -} - -type DumpVarsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DumpVarsRequest) Reset() { - *x = DumpVarsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DumpVarsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DumpVarsRequest) ProtoMessage() {} - -func (x *DumpVarsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[33] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DumpVarsRequest.ProtoReflect.Descriptor instead. -func (*DumpVarsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{33} -} - -type DumpVarsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"` - Contents map[string]string `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DumpVarsResponse) Reset() { - *x = DumpVarsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DumpVarsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DumpVarsResponse) ProtoMessage() {} - -func (x *DumpVarsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[34] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DumpVarsResponse.ProtoReflect.Descriptor instead. -func (*DumpVarsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{34} -} - -func (x *DumpVarsResponse) GetAlert() string { - if x != nil { - return x.Alert - } - return "" -} - -func (x *DumpVarsResponse) GetContents() map[string]string { - if x != nil { - return x.Contents - } - return nil -} - -type DebugBinding struct { - state protoimpl.MessageState `protogen:"open.v1"` - ActionTitle string `protobuf:"bytes,1,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"` - EntityPrefix string `protobuf:"bytes,2,opt,name=entity_prefix,json=entityPrefix,proto3" json:"entity_prefix,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DebugBinding) Reset() { - *x = DebugBinding{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DebugBinding) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DebugBinding) ProtoMessage() {} - -func (x *DebugBinding) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[35] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DebugBinding.ProtoReflect.Descriptor instead. -func (*DebugBinding) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{35} -} - -func (x *DebugBinding) GetActionTitle() string { - if x != nil { - return x.ActionTitle - } - return "" -} - -func (x *DebugBinding) GetEntityPrefix() string { - if x != nil { - return x.EntityPrefix - } - return "" -} - -type DumpPublicIdActionMapRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DumpPublicIdActionMapRequest) Reset() { - *x = DumpPublicIdActionMapRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DumpPublicIdActionMapRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DumpPublicIdActionMapRequest) ProtoMessage() {} - -func (x *DumpPublicIdActionMapRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[36] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DumpPublicIdActionMapRequest.ProtoReflect.Descriptor instead. -func (*DumpPublicIdActionMapRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{36} -} - -type DumpPublicIdActionMapResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Alert string `protobuf:"bytes,1,opt,name=alert,proto3" json:"alert,omitempty"` - Contents map[string]*DebugBinding `protobuf:"bytes,2,rep,name=contents,proto3" json:"contents,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DumpPublicIdActionMapResponse) Reset() { - *x = DumpPublicIdActionMapResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DumpPublicIdActionMapResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DumpPublicIdActionMapResponse) ProtoMessage() {} - -func (x *DumpPublicIdActionMapResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[37] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use DumpPublicIdActionMapResponse.ProtoReflect.Descriptor instead. -func (*DumpPublicIdActionMapResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{37} -} - -func (x *DumpPublicIdActionMapResponse) GetAlert() string { - if x != nil { - return x.Alert - } - return "" -} - -func (x *DumpPublicIdActionMapResponse) GetContents() map[string]*DebugBinding { - if x != nil { - return x.Contents - } - return nil -} - -type GetReadyzRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetReadyzRequest) Reset() { - *x = GetReadyzRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetReadyzRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetReadyzRequest) ProtoMessage() {} - -func (x *GetReadyzRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[38] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetReadyzRequest.ProtoReflect.Descriptor instead. -func (*GetReadyzRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{38} -} - -type GetReadyzResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetReadyzResponse) Reset() { - *x = GetReadyzResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetReadyzResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetReadyzResponse) ProtoMessage() {} - -func (x *GetReadyzResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[39] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetReadyzResponse.ProtoReflect.Descriptor instead. -func (*GetReadyzResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{39} -} - -func (x *GetReadyzResponse) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -type EventStreamRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventStreamRequest) Reset() { - *x = EventStreamRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventStreamRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventStreamRequest) ProtoMessage() {} - -func (x *EventStreamRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[40] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventStreamRequest.ProtoReflect.Descriptor instead. -func (*EventStreamRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{40} -} - -type EventStreamResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Types that are valid to be assigned to Event: - // - // *EventStreamResponse_EntityChanged - // *EventStreamResponse_ConfigChanged - // *EventStreamResponse_ExecutionFinished - // *EventStreamResponse_ExecutionStarted - // *EventStreamResponse_OutputChunk - Event isEventStreamResponse_Event `protobuf_oneof:"event"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventStreamResponse) Reset() { - *x = EventStreamResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventStreamResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventStreamResponse) ProtoMessage() {} - -func (x *EventStreamResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[41] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventStreamResponse.ProtoReflect.Descriptor instead. -func (*EventStreamResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{41} -} - -func (x *EventStreamResponse) GetEvent() isEventStreamResponse_Event { - if x != nil { - return x.Event - } - return nil -} - -func (x *EventStreamResponse) GetEntityChanged() *EventEntityChanged { - if x != nil { - if x, ok := x.Event.(*EventStreamResponse_EntityChanged); ok { - return x.EntityChanged - } - } - return nil -} - -func (x *EventStreamResponse) GetConfigChanged() *EventConfigChanged { - if x != nil { - if x, ok := x.Event.(*EventStreamResponse_ConfigChanged); ok { - return x.ConfigChanged - } - } - return nil -} - -func (x *EventStreamResponse) GetExecutionFinished() *EventExecutionFinished { - if x != nil { - if x, ok := x.Event.(*EventStreamResponse_ExecutionFinished); ok { - return x.ExecutionFinished - } - } - return nil -} - -func (x *EventStreamResponse) GetExecutionStarted() *EventExecutionStarted { - if x != nil { - if x, ok := x.Event.(*EventStreamResponse_ExecutionStarted); ok { - return x.ExecutionStarted - } - } - return nil -} - -func (x *EventStreamResponse) GetOutputChunk() *EventOutputChunk { - if x != nil { - if x, ok := x.Event.(*EventStreamResponse_OutputChunk); ok { - return x.OutputChunk - } - } - return nil -} - -type isEventStreamResponse_Event interface { - isEventStreamResponse_Event() -} - -type EventStreamResponse_EntityChanged struct { - EntityChanged *EventEntityChanged `protobuf:"bytes,2,opt,name=entity_changed,json=entityChanged,proto3,oneof"` -} - -type EventStreamResponse_ConfigChanged struct { - ConfigChanged *EventConfigChanged `protobuf:"bytes,3,opt,name=config_changed,json=configChanged,proto3,oneof"` -} - -type EventStreamResponse_ExecutionFinished struct { - ExecutionFinished *EventExecutionFinished `protobuf:"bytes,4,opt,name=execution_finished,json=executionFinished,proto3,oneof"` -} - -type EventStreamResponse_ExecutionStarted struct { - ExecutionStarted *EventExecutionStarted `protobuf:"bytes,5,opt,name=execution_started,json=executionStarted,proto3,oneof"` -} - -type EventStreamResponse_OutputChunk struct { - OutputChunk *EventOutputChunk `protobuf:"bytes,6,opt,name=output_chunk,json=outputChunk,proto3,oneof"` -} - -func (*EventStreamResponse_EntityChanged) isEventStreamResponse_Event() {} - -func (*EventStreamResponse_ConfigChanged) isEventStreamResponse_Event() {} - -func (*EventStreamResponse_ExecutionFinished) isEventStreamResponse_Event() {} - -func (*EventStreamResponse_ExecutionStarted) isEventStreamResponse_Event() {} - -func (*EventStreamResponse_OutputChunk) isEventStreamResponse_Event() {} - -type EventOutputChunk struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - Output string `protobuf:"bytes,2,opt,name=output,proto3" json:"output,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventOutputChunk) Reset() { - *x = EventOutputChunk{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventOutputChunk) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventOutputChunk) ProtoMessage() {} - -func (x *EventOutputChunk) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[42] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventOutputChunk.ProtoReflect.Descriptor instead. -func (*EventOutputChunk) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{42} -} - -func (x *EventOutputChunk) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -func (x *EventOutputChunk) GetOutput() string { - if x != nil { - return x.Output - } - return "" -} - -type EventEntityChanged struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventEntityChanged) Reset() { - *x = EventEntityChanged{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventEntityChanged) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventEntityChanged) ProtoMessage() {} - -func (x *EventEntityChanged) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[43] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventEntityChanged.ProtoReflect.Descriptor instead. -func (*EventEntityChanged) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{43} -} - -type EventConfigChanged struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventConfigChanged) Reset() { - *x = EventConfigChanged{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventConfigChanged) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventConfigChanged) ProtoMessage() {} - -func (x *EventConfigChanged) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[44] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventConfigChanged.ProtoReflect.Descriptor instead. -func (*EventConfigChanged) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{44} -} - -type EventExecutionFinished struct { - state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventExecutionFinished) Reset() { - *x = EventExecutionFinished{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventExecutionFinished) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventExecutionFinished) ProtoMessage() {} - -func (x *EventExecutionFinished) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[45] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventExecutionFinished.ProtoReflect.Descriptor instead. -func (*EventExecutionFinished) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{45} -} - -func (x *EventExecutionFinished) GetLogEntry() *LogEntry { - if x != nil { - return x.LogEntry - } - return nil -} - -type EventExecutionStarted struct { - state protoimpl.MessageState `protogen:"open.v1"` - LogEntry *LogEntry `protobuf:"bytes,1,opt,name=log_entry,json=logEntry,proto3" json:"log_entry,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EventExecutionStarted) Reset() { - *x = EventExecutionStarted{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EventExecutionStarted) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EventExecutionStarted) ProtoMessage() {} - -func (x *EventExecutionStarted) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[46] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EventExecutionStarted.ProtoReflect.Descriptor instead. -func (*EventExecutionStarted) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{46} -} - -func (x *EventExecutionStarted) GetLogEntry() *LogEntry { - if x != nil { - return x.LogEntry - } - return nil -} - -type KillActionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *KillActionRequest) Reset() { - *x = KillActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *KillActionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*KillActionRequest) ProtoMessage() {} - -func (x *KillActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[47] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use KillActionRequest.ProtoReflect.Descriptor instead. -func (*KillActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{47} -} - -func (x *KillActionRequest) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -type KillActionResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - Killed bool `protobuf:"varint,2,opt,name=killed,proto3" json:"killed,omitempty"` - AlreadyCompleted bool `protobuf:"varint,3,opt,name=already_completed,json=alreadyCompleted,proto3" json:"already_completed,omitempty"` - Found bool `protobuf:"varint,4,opt,name=found,proto3" json:"found,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *KillActionResponse) Reset() { - *x = KillActionResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *KillActionResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*KillActionResponse) ProtoMessage() {} - -func (x *KillActionResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[48] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use KillActionResponse.ProtoReflect.Descriptor instead. -func (*KillActionResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{48} -} - -func (x *KillActionResponse) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -func (x *KillActionResponse) GetKilled() bool { - if x != nil { - return x.Killed - } - return false -} - -func (x *KillActionResponse) GetAlreadyCompleted() bool { - if x != nil { - return x.AlreadyCompleted - } - return false -} - -func (x *KillActionResponse) GetFound() bool { - if x != nil { - return x.Found - } - return false -} - -type LocalUserLoginRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` - Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LocalUserLoginRequest) Reset() { - *x = LocalUserLoginRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LocalUserLoginRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LocalUserLoginRequest) ProtoMessage() {} - -func (x *LocalUserLoginRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[49] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LocalUserLoginRequest.ProtoReflect.Descriptor instead. -func (*LocalUserLoginRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{49} -} - -func (x *LocalUserLoginRequest) GetUsername() string { - if x != nil { - return x.Username - } - return "" -} - -func (x *LocalUserLoginRequest) GetPassword() string { - if x != nil { - return x.Password - } - return "" -} - -type LocalUserLoginResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LocalUserLoginResponse) Reset() { - *x = LocalUserLoginResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LocalUserLoginResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LocalUserLoginResponse) ProtoMessage() {} - -func (x *LocalUserLoginResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[50] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LocalUserLoginResponse.ProtoReflect.Descriptor instead. -func (*LocalUserLoginResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{50} -} - -func (x *LocalUserLoginResponse) GetSuccess() bool { - if x != nil { - return x.Success - } - return false -} - -type PasswordHashRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PasswordHashRequest) Reset() { - *x = PasswordHashRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PasswordHashRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PasswordHashRequest) ProtoMessage() {} - -func (x *PasswordHashRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[51] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PasswordHashRequest.ProtoReflect.Descriptor instead. -func (*PasswordHashRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{51} -} - -func (x *PasswordHashRequest) GetPassword() string { - if x != nil { - return x.Password - } - return "" -} - -type PasswordHashResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Hash string `protobuf:"bytes,1,opt,name=hash,proto3" json:"hash,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *PasswordHashResponse) Reset() { - *x = PasswordHashResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *PasswordHashResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*PasswordHashResponse) ProtoMessage() {} - -func (x *PasswordHashResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[52] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use PasswordHashResponse.ProtoReflect.Descriptor instead. -func (*PasswordHashResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{52} -} - -func (x *PasswordHashResponse) GetHash() string { - if x != nil { - return x.Hash - } - return "" -} - -type LogoutRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LogoutRequest) Reset() { - *x = LogoutRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LogoutRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LogoutRequest) ProtoMessage() {} - -func (x *LogoutRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[53] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LogoutRequest.ProtoReflect.Descriptor instead. -func (*LogoutRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{53} -} - -type LogoutResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *LogoutResponse) Reset() { - *x = LogoutResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *LogoutResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*LogoutResponse) ProtoMessage() {} - -func (x *LogoutResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[54] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use LogoutResponse.ProtoReflect.Descriptor instead. -func (*LogoutResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{54} -} - -type GetDiagnosticsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDiagnosticsRequest) Reset() { - *x = GetDiagnosticsRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDiagnosticsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDiagnosticsRequest) ProtoMessage() {} - -func (x *GetDiagnosticsRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[55] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDiagnosticsRequest.ProtoReflect.Descriptor instead. -func (*GetDiagnosticsRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{55} -} - -type GetDiagnosticsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - SshFoundKey string `protobuf:"bytes,1,opt,name=SshFoundKey,proto3" json:"SshFoundKey,omitempty"` - SshFoundConfig string `protobuf:"bytes,2,opt,name=SshFoundConfig,proto3" json:"SshFoundConfig,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetDiagnosticsResponse) Reset() { - *x = GetDiagnosticsResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetDiagnosticsResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetDiagnosticsResponse) ProtoMessage() {} - -func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[56] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead. -func (*GetDiagnosticsResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{56} -} - -func (x *GetDiagnosticsResponse) GetSshFoundKey() string { - if x != nil { - return x.SshFoundKey - } - return "" -} - -func (x *GetDiagnosticsResponse) GetSshFoundConfig() string { - if x != nil { - return x.SshFoundConfig - } - return "" -} - -type InitRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *InitRequest) Reset() { - *x = InitRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *InitRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*InitRequest) ProtoMessage() {} - -func (x *InitRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[57] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use InitRequest.ProtoReflect.Descriptor instead. -func (*InitRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{57} -} - -type InitResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ShowFooter bool `protobuf:"varint,1,opt,name=showFooter,proto3" json:"showFooter,omitempty"` - ShowNavigation bool `protobuf:"varint,2,opt,name=showNavigation,proto3" json:"showNavigation,omitempty"` - ShowNewVersions bool `protobuf:"varint,3,opt,name=showNewVersions,proto3" json:"showNewVersions,omitempty"` - AvailableVersion string `protobuf:"bytes,4,opt,name=availableVersion,proto3" json:"availableVersion,omitempty"` - CurrentVersion string `protobuf:"bytes,5,opt,name=currentVersion,proto3" json:"currentVersion,omitempty"` - PageTitle string `protobuf:"bytes,6,opt,name=pageTitle,proto3" json:"pageTitle,omitempty"` - SectionNavigationStyle string `protobuf:"bytes,7,opt,name=sectionNavigationStyle,proto3" json:"sectionNavigationStyle,omitempty"` - DefaultIconForBack string `protobuf:"bytes,8,opt,name=defaultIconForBack,proto3" json:"defaultIconForBack,omitempty"` - EnableCustomJs bool `protobuf:"varint,9,opt,name=enableCustomJs,proto3" json:"enableCustomJs,omitempty"` - AuthLoginUrl string `protobuf:"bytes,10,opt,name=authLoginUrl,proto3" json:"authLoginUrl,omitempty"` - AuthLocalLogin bool `protobuf:"varint,11,opt,name=authLocalLogin,proto3" json:"authLocalLogin,omitempty"` - StyleMods []string `protobuf:"bytes,12,rep,name=styleMods,proto3" json:"styleMods,omitempty"` - OAuth2Providers []*OAuth2Provider `protobuf:"bytes,13,rep,name=oAuth2Providers,proto3" json:"oAuth2Providers,omitempty"` - AdditionalLinks []*AdditionalLink `protobuf:"bytes,14,rep,name=additionalLinks,proto3" json:"additionalLinks,omitempty"` - RootDashboards []string `protobuf:"bytes,15,rep,name=rootDashboards,proto3" json:"rootDashboards,omitempty"` - AuthenticatedUser string `protobuf:"bytes,16,opt,name=authenticated_user,json=authenticatedUser,proto3" json:"authenticated_user,omitempty"` - AuthenticatedUserProvider string `protobuf:"bytes,17,opt,name=authenticated_user_provider,json=authenticatedUserProvider,proto3" json:"authenticated_user_provider,omitempty"` - EffectivePolicy *EffectivePolicy `protobuf:"bytes,18,opt,name=effective_policy,json=effectivePolicy,proto3" json:"effective_policy,omitempty"` - BannerMessage string `protobuf:"bytes,19,opt,name=banner_message,json=bannerMessage,proto3" json:"banner_message,omitempty"` - BannerCss string `protobuf:"bytes,20,opt,name=banner_css,json=bannerCss,proto3" json:"banner_css,omitempty"` - ShowDiagnostics bool `protobuf:"varint,21,opt,name=show_diagnostics,json=showDiagnostics,proto3" json:"show_diagnostics,omitempty"` - ShowLogList bool `protobuf:"varint,22,opt,name=show_log_list,json=showLogList,proto3" json:"show_log_list,omitempty"` - LoginRequired bool `protobuf:"varint,23,opt,name=login_required,json=loginRequired,proto3" json:"login_required,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *InitResponse) Reset() { - *x = InitResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *InitResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*InitResponse) ProtoMessage() {} - -func (x *InitResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[58] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use InitResponse.ProtoReflect.Descriptor instead. -func (*InitResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{58} -} - -func (x *InitResponse) GetShowFooter() bool { - if x != nil { - return x.ShowFooter - } - return false -} - -func (x *InitResponse) GetShowNavigation() bool { - if x != nil { - return x.ShowNavigation - } - return false -} - -func (x *InitResponse) GetShowNewVersions() bool { - if x != nil { - return x.ShowNewVersions - } - return false -} - -func (x *InitResponse) GetAvailableVersion() string { - if x != nil { - return x.AvailableVersion - } - return "" -} - -func (x *InitResponse) GetCurrentVersion() string { - if x != nil { - return x.CurrentVersion - } - return "" -} - -func (x *InitResponse) GetPageTitle() string { - if x != nil { - return x.PageTitle - } - return "" -} - -func (x *InitResponse) GetSectionNavigationStyle() string { - if x != nil { - return x.SectionNavigationStyle - } - return "" -} - -func (x *InitResponse) GetDefaultIconForBack() string { - if x != nil { - return x.DefaultIconForBack - } - return "" -} - -func (x *InitResponse) GetEnableCustomJs() bool { - if x != nil { - return x.EnableCustomJs - } - return false -} - -func (x *InitResponse) GetAuthLoginUrl() string { - if x != nil { - return x.AuthLoginUrl - } - return "" -} - -func (x *InitResponse) GetAuthLocalLogin() bool { - if x != nil { - return x.AuthLocalLogin - } - return false -} - -func (x *InitResponse) GetStyleMods() []string { - if x != nil { - return x.StyleMods - } - return nil -} - -func (x *InitResponse) GetOAuth2Providers() []*OAuth2Provider { - if x != nil { - return x.OAuth2Providers - } - return nil -} - -func (x *InitResponse) GetAdditionalLinks() []*AdditionalLink { - if x != nil { - return x.AdditionalLinks - } - return nil -} - -func (x *InitResponse) GetRootDashboards() []string { - if x != nil { - return x.RootDashboards - } - return nil -} - -func (x *InitResponse) GetAuthenticatedUser() string { - if x != nil { - return x.AuthenticatedUser - } - return "" -} - -func (x *InitResponse) GetAuthenticatedUserProvider() string { - if x != nil { - return x.AuthenticatedUserProvider - } - return "" -} - -func (x *InitResponse) GetEffectivePolicy() *EffectivePolicy { - if x != nil { - return x.EffectivePolicy - } - return nil -} - -func (x *InitResponse) GetBannerMessage() string { - if x != nil { - return x.BannerMessage - } - return "" -} - -func (x *InitResponse) GetBannerCss() string { - if x != nil { - return x.BannerCss - } - return "" -} - -func (x *InitResponse) GetShowDiagnostics() bool { - if x != nil { - return x.ShowDiagnostics - } - return false -} - -func (x *InitResponse) GetShowLogList() bool { - if x != nil { - return x.ShowLogList - } - return false -} - -func (x *InitResponse) GetLoginRequired() bool { - if x != nil { - return x.LoginRequired - } - return false -} - -type AdditionalLink struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *AdditionalLink) Reset() { - *x = AdditionalLink{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *AdditionalLink) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*AdditionalLink) ProtoMessage() {} - -func (x *AdditionalLink) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[59] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead. -func (*AdditionalLink) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{59} -} - -func (x *AdditionalLink) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *AdditionalLink) GetUrl() string { - if x != nil { - return x.Url - } - return "" -} - -type OAuth2Provider struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Icon string `protobuf:"bytes,3,opt,name=icon,proto3" json:"icon,omitempty"` - Key string `protobuf:"bytes,4,opt,name=key,proto3" json:"key,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *OAuth2Provider) Reset() { - *x = OAuth2Provider{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *OAuth2Provider) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*OAuth2Provider) ProtoMessage() {} - -func (x *OAuth2Provider) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[60] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead. -func (*OAuth2Provider) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{60} -} - -func (x *OAuth2Provider) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *OAuth2Provider) GetIcon() string { - if x != nil { - return x.Icon - } - return "" -} - -func (x *OAuth2Provider) GetKey() string { - if x != nil { - return x.Key - } - return "" -} - -type GetActionBindingRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - BindingId string `protobuf:"bytes,1,opt,name=binding_id,json=bindingId,proto3" json:"binding_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetActionBindingRequest) Reset() { - *x = GetActionBindingRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetActionBindingRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetActionBindingRequest) ProtoMessage() {} - -func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[61] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead. -func (*GetActionBindingRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{61} -} - -func (x *GetActionBindingRequest) GetBindingId() string { - if x != nil { - return x.BindingId - } - return "" -} - -type GetActionBindingResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Action *Action `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetActionBindingResponse) Reset() { - *x = GetActionBindingResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetActionBindingResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetActionBindingResponse) ProtoMessage() {} - -func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[62] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead. -func (*GetActionBindingResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{62} -} - -func (x *GetActionBindingResponse) GetAction() *Action { - if x != nil { - return x.Action - } - return nil -} - -type GetEntitiesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetEntitiesRequest) Reset() { - *x = GetEntitiesRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetEntitiesRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetEntitiesRequest) ProtoMessage() {} - -func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[63] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead. -func (*GetEntitiesRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{63} -} - -type GetEntitiesResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - EntityDefinitions []*EntityDefinition `protobuf:"bytes,1,rep,name=entity_definitions,json=entityDefinitions,proto3" json:"entity_definitions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetEntitiesResponse) Reset() { - *x = GetEntitiesResponse{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetEntitiesResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetEntitiesResponse) ProtoMessage() {} - -func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[64] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead. -func (*GetEntitiesResponse) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64} -} - -func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition { - if x != nil { - return x.EntityDefinitions - } - return nil -} - -type EntityDefinition struct { - state protoimpl.MessageState `protogen:"open.v1"` - Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` - Instances []*Entity `protobuf:"bytes,2,rep,name=instances,proto3" json:"instances,omitempty"` - UsedOnDashboards []string `protobuf:"bytes,3,rep,name=used_on_dashboards,json=usedOnDashboards,proto3" json:"used_on_dashboards,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *EntityDefinition) Reset() { - *x = EntityDefinition{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *EntityDefinition) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*EntityDefinition) ProtoMessage() {} - -func (x *EntityDefinition) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead. -func (*EntityDefinition) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65} -} - -func (x *EntityDefinition) GetTitle() string { - if x != nil { - return x.Title - } - return "" -} - -func (x *EntityDefinition) GetInstances() []*Entity { - if x != nil { - return x.Instances - } - return nil -} - -func (x *EntityDefinition) GetUsedOnDashboards() []string { - if x != nil { - return x.UsedOnDashboards - } - return nil -} - -type GetEntityRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - UniqueKey string `protobuf:"bytes,1,opt,name=unique_key,json=uniqueKey,proto3" json:"unique_key,omitempty"` - Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetEntityRequest) Reset() { - *x = GetEntityRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetEntityRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetEntityRequest) ProtoMessage() {} - -func (x *GetEntityRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead. -func (*GetEntityRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66} -} - -func (x *GetEntityRequest) GetUniqueKey() string { - if x != nil { - return x.UniqueKey - } - return "" -} - -func (x *GetEntityRequest) GetType() string { - if x != nil { - return x.Type - } - return "" -} - -type RestartActionRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ExecutionTrackingId string `protobuf:"bytes,1,opt,name=execution_tracking_id,json=executionTrackingId,proto3" json:"execution_tracking_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RestartActionRequest) Reset() { - *x = RestartActionRequest{} - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RestartActionRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RestartActionRequest) ProtoMessage() {} - -func (x *RestartActionRequest) ProtoReflect() protoreflect.Message { - mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead. -func (*RestartActionRequest) Descriptor() ([]byte, []int) { - return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67} -} - -func (x *RestartActionRequest) GetExecutionTrackingId() string { - if x != nil { - return x.ExecutionTrackingId - } - return "" -} - -var File_olivetin_api_v1_olivetin_proto protoreflect.FileDescriptor - -const file_olivetin_api_v1_olivetin_proto_rawDesc = "" + - "\n" + - "\x1eolivetin/api/v1/olivetin.proto\x12\x0folivetin.api.v1\"\xc0\x02\n" + - "\x06Action\x12\x1d\n" + - "\n" + - "binding_id\x18\x01 \x01(\tR\tbindingId\x12\x14\n" + - "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + - "\x04icon\x18\x03 \x01(\tR\x04icon\x12\x19\n" + - "\bcan_exec\x18\x04 \x01(\bR\acanExec\x12=\n" + - "\targuments\x18\x05 \x03(\v2\x1f.olivetin.api.v1.ActionArgumentR\targuments\x12$\n" + - "\x0epopup_on_start\x18\x06 \x01(\tR\fpopupOnStart\x12\x14\n" + - "\x05order\x18\a \x01(\x05R\x05order\x12\x18\n" + - "\atimeout\x18\b \x01(\x05R\atimeout\x12=\n" + - "\x1bdatetime_rate_limit_expires\x18\t \x01(\tR\x18datetimeRateLimitExpires\"\xa2\x03\n" + - "\x0eActionArgument\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05title\x18\x02 \x01(\tR\x05title\x12\x12\n" + - "\x04type\x18\x03 \x01(\tR\x04type\x12#\n" + - "\rdefault_value\x18\x04 \x01(\tR\fdefaultValue\x12?\n" + - "\achoices\x18\x05 \x03(\v2%.olivetin.api.v1.ActionArgumentChoiceR\achoices\x12 \n" + - "\vdescription\x18\x06 \x01(\tR\vdescription\x12R\n" + - "\vsuggestions\x18\a \x03(\v20.olivetin.api.v1.ActionArgument.SuggestionsEntryR\vsuggestions\x126\n" + - "\x17suggestions_browser_key\x18\b \x01(\tR\x15suggestionsBrowserKey\x1a>\n" + - "\x10SuggestionsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" + - "\x14ActionArgumentChoice\x12\x14\n" + - "\x05value\x18\x01 \x01(\tR\x05value\x12\x14\n" + - "\x05title\x18\x02 \x01(\tR\x05title\"\xeb\x01\n" + - "\x06Entity\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12\x1d\n" + - "\n" + - "unique_key\x18\x02 \x01(\tR\tuniqueKey\x12\x12\n" + - "\x04type\x18\x03 \x01(\tR\x04type\x12 \n" + - "\vdirectories\x18\x04 \x03(\tR\vdirectories\x12;\n" + - "\x06fields\x18\x05 \x03(\v2#.olivetin.api.v1.Entity.FieldsEntryR\x06fields\x1a9\n" + - "\vFieldsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"f\n" + - "\x14GetDashboardResponse\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x128\n" + - "\tdashboard\x18\x04 \x01(\v2\x1a.olivetin.api.v1.DashboardR\tdashboard\"`\n" + - "\x0fEffectivePolicy\x12)\n" + - "\x10show_diagnostics\x18\x01 \x01(\bR\x0fshowDiagnostics\x12\"\n" + - "\rshow_log_list\x18\x02 \x01(\bR\vshowLogList\"k\n" + - "\x13GetDashboardRequest\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12\x1f\n" + - "\ventity_type\x18\x02 \x01(\tR\n" + - "entityType\x12\x1d\n" + - "\n" + - "entity_key\x18\x03 \x01(\tR\tentityKey\"b\n" + - "\tDashboard\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12?\n" + - "\bcontents\x18\x02 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\"\xa1\x02\n" + - "\x12DashboardComponent\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12?\n" + - "\bcontents\x18\x03 \x03(\v2#.olivetin.api.v1.DashboardComponentR\bcontents\x12\x12\n" + - "\x04icon\x18\x04 \x01(\tR\x04icon\x12\x1b\n" + - "\tcss_class\x18\x05 \x01(\tR\bcssClass\x12/\n" + - "\x06action\x18\x06 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\x12\x1f\n" + - "\ventity_type\x18\a \x01(\tR\n" + - "entityType\x12\x1d\n" + - "\n" + - "entity_key\x18\b \x01(\tR\tentityKey\"\xa5\x01\n" + - "\x12StartActionRequest\x12\x1d\n" + - "\n" + - "binding_id\x18\x01 \x01(\tR\tbindingId\x12B\n" + - "\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\x12,\n" + - "\x12unique_tracking_id\x18\x03 \x01(\tR\x10uniqueTrackingId\"?\n" + - "\x13StartActionArgument\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value\"I\n" + - "\x13StartActionResponse\x122\n" + - "\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"|\n" + - "\x19StartActionAndWaitRequest\x12\x1b\n" + - "\taction_id\x18\x01 \x01(\tR\bactionId\x12B\n" + - "\targuments\x18\x02 \x03(\v2$.olivetin.api.v1.StartActionArgumentR\targuments\"T\n" + - "\x1aStartActionAndWaitResponse\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"6\n" + - "\x17StartActionByGetRequest\x12\x1b\n" + - "\taction_id\x18\x01 \x01(\tR\bactionId\"N\n" + - "\x18StartActionByGetResponse\x122\n" + - "\x15execution_tracking_id\x18\x02 \x01(\tR\x13executionTrackingId\"=\n" + - "\x1eStartActionByGetAndWaitRequest\x12\x1b\n" + - "\taction_id\x18\x01 \x01(\tR\bactionId\"Y\n" + - "\x1fStartActionByGetAndWaitResponse\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"T\n" + - "\x0eGetLogsRequest\x12!\n" + - "\fstart_offset\x18\x01 \x01(\x03R\vstartOffset\x12\x1f\n" + - "\vdate_filter\x18\x02 \x01(\tR\n" + - "dateFilter\"\x89\x05\n" + - "\bLogEntry\x12)\n" + - "\x10datetime_started\x18\x01 \x01(\tR\x0fdatetimeStarted\x12!\n" + - "\faction_title\x18\x02 \x01(\tR\vactionTitle\x12\x16\n" + - "\x06output\x18\x03 \x01(\tR\x06output\x12\x1b\n" + - "\ttimed_out\x18\x05 \x01(\bR\btimedOut\x12\x1b\n" + - "\texit_code\x18\x06 \x01(\x05R\bexitCode\x12\x12\n" + - "\x04user\x18\a \x01(\tR\x04user\x12\x1d\n" + - "\n" + - "user_class\x18\b \x01(\tR\tuserClass\x12\x1f\n" + - "\vaction_icon\x18\t \x01(\tR\n" + - "actionIcon\x12\x12\n" + - "\x04tags\x18\n" + - " \x03(\tR\x04tags\x122\n" + - "\x15execution_tracking_id\x18\v \x01(\tR\x13executionTrackingId\x12+\n" + - "\x11datetime_finished\x18\f \x01(\tR\x10datetimeFinished\x12+\n" + - "\x11execution_started\x18\x0e \x01(\bR\x10executionStarted\x12-\n" + - "\x12execution_finished\x18\x0f \x01(\bR\x11executionFinished\x12\x18\n" + - "\ablocked\x18\x10 \x01(\bR\ablocked\x12%\n" + - "\x0edatetime_index\x18\x11 \x01(\x03R\rdatetimeIndex\x12\x19\n" + - "\bcan_kill\x18\x12 \x01(\bR\acanKill\x12=\n" + - "\x1bdatetime_rate_limit_expires\x18\x13 \x01(\tR\x18datetimeRateLimitExpires\x12\x1d\n" + - "\n" + - "binding_id\x18\x14 \x01(\tR\tbindingId\"\xca\x01\n" + - "\x0fGetLogsResponse\x12-\n" + - "\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" + - "\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" + - "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x1f\n" + - "\vtotal_count\x18\x04 \x01(\x03R\n" + - "totalCount\x12!\n" + - "\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"V\n" + - "\x14GetActionLogsRequest\x12\x1b\n" + - "\taction_id\x18\x01 \x01(\tR\bactionId\x12!\n" + - "\fstart_offset\x18\x02 \x01(\x03R\vstartOffset\"\xd0\x01\n" + - "\x15GetActionLogsResponse\x12-\n" + - "\x04logs\x18\x01 \x03(\v2\x19.olivetin.api.v1.LogEntryR\x04logs\x12'\n" + - "\x0fcount_remaining\x18\x02 \x01(\x03R\x0ecountRemaining\x12\x1b\n" + - "\tpage_size\x18\x03 \x01(\x03R\bpageSize\x12\x1f\n" + - "\vtotal_count\x18\x04 \x01(\x03R\n" + - "totalCount\x12!\n" + - "\fstart_offset\x18\x05 \x01(\x03R\vstartOffset\"\x8b\x01\n" + - "\x1bValidateArgumentTypeRequest\x12\x14\n" + - "\x05value\x18\x01 \x01(\tR\x05value\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\x12\x1d\n" + - "\n" + - "binding_id\x18\x03 \x01(\tR\tbindingId\x12#\n" + - "\rargument_name\x18\x04 \x01(\tR\fargumentName\"V\n" + - "\x1cValidateArgumentTypeResponse\x12\x14\n" + - "\x05valid\x18\x01 \x01(\bR\x05valid\x12 \n" + - "\vdescription\x18\x02 \x01(\tR\vdescription\"K\n" + - "\x15WatchExecutionRequest\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\".\n" + - "\x14WatchExecutionUpdate\x12\x16\n" + - "\x06update\x18\x01 \x01(\tR\x06update\"i\n" + - "\x16ExecutionStatusRequest\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x1b\n" + - "\taction_id\x18\x02 \x01(\tR\bactionId\"Q\n" + - "\x17ExecutionStatusResponse\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"\x0f\n" + - "\rWhoAmIRequest\"\x9f\x01\n" + - "\x0eWhoAmIResponse\x12-\n" + - "\x12authenticated_user\x18\x01 \x01(\tR\x11authenticatedUser\x12\x1c\n" + - "\tusergroup\x18\x02 \x01(\tR\tusergroup\x12\x1a\n" + - "\bprovider\x18\x03 \x01(\tR\bprovider\x12\x12\n" + - "\x04acls\x18\x04 \x03(\tR\x04acls\x12\x10\n" + - "\x03sid\x18\x05 \x01(\tR\x03sid\"\x12\n" + - "\x10SosReportRequest\")\n" + - "\x11SosReportResponse\x12\x14\n" + - "\x05alert\x18\x01 \x01(\tR\x05alert\"\x11\n" + - "\x0fDumpVarsRequest\"\xb2\x01\n" + - "\x10DumpVarsResponse\x12\x14\n" + - "\x05alert\x18\x01 \x01(\tR\x05alert\x12K\n" + - "\bcontents\x18\x02 \x03(\v2/.olivetin.api.v1.DumpVarsResponse.ContentsEntryR\bcontents\x1a;\n" + - "\rContentsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"V\n" + - "\fDebugBinding\x12!\n" + - "\faction_title\x18\x01 \x01(\tR\vactionTitle\x12#\n" + - "\rentity_prefix\x18\x02 \x01(\tR\fentityPrefix\"\x1e\n" + - "\x1cDumpPublicIdActionMapRequest\"\xeb\x01\n" + - "\x1dDumpPublicIdActionMapResponse\x12\x14\n" + - "\x05alert\x18\x01 \x01(\tR\x05alert\x12X\n" + - "\bcontents\x18\x02 \x03(\v2<.olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntryR\bcontents\x1aZ\n" + - "\rContentsEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x123\n" + - "\x05value\x18\x02 \x01(\v2\x1d.olivetin.api.v1.DebugBindingR\x05value:\x028\x01\"\x12\n" + - "\x10GetReadyzRequest\"+\n" + - "\x11GetReadyzResponse\x12\x16\n" + - "\x06status\x18\x01 \x01(\tR\x06status\"\x14\n" + - "\x12EventStreamRequest\"\xb3\x03\n" + - "\x13EventStreamResponse\x12L\n" + - "\x0eentity_changed\x18\x02 \x01(\v2#.olivetin.api.v1.EventEntityChangedH\x00R\rentityChanged\x12L\n" + - "\x0econfig_changed\x18\x03 \x01(\v2#.olivetin.api.v1.EventConfigChangedH\x00R\rconfigChanged\x12X\n" + - "\x12execution_finished\x18\x04 \x01(\v2'.olivetin.api.v1.EventExecutionFinishedH\x00R\x11executionFinished\x12U\n" + - "\x11execution_started\x18\x05 \x01(\v2&.olivetin.api.v1.EventExecutionStartedH\x00R\x10executionStarted\x12F\n" + - "\foutput_chunk\x18\x06 \x01(\v2!.olivetin.api.v1.EventOutputChunkH\x00R\voutputChunkB\a\n" + - "\x05event\"^\n" + - "\x10EventOutputChunk\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x16\n" + - "\x06output\x18\x02 \x01(\tR\x06output\"\x14\n" + - "\x12EventEntityChanged\"\x14\n" + - "\x12EventConfigChanged\"P\n" + - "\x16EventExecutionFinished\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"O\n" + - "\x15EventExecutionStarted\x126\n" + - "\tlog_entry\x18\x01 \x01(\v2\x19.olivetin.api.v1.LogEntryR\blogEntry\"G\n" + - "\x11KillActionRequest\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\"\xa3\x01\n" + - "\x12KillActionResponse\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId\x12\x16\n" + - "\x06killed\x18\x02 \x01(\bR\x06killed\x12+\n" + - "\x11already_completed\x18\x03 \x01(\bR\x10alreadyCompleted\x12\x14\n" + - "\x05found\x18\x04 \x01(\bR\x05found\"O\n" + - "\x15LocalUserLoginRequest\x12\x1a\n" + - "\busername\x18\x01 \x01(\tR\busername\x12\x1a\n" + - "\bpassword\x18\x02 \x01(\tR\bpassword\"2\n" + - "\x16LocalUserLoginResponse\x12\x18\n" + - "\asuccess\x18\x01 \x01(\bR\asuccess\"1\n" + - "\x13PasswordHashRequest\x12\x1a\n" + - "\bpassword\x18\x01 \x01(\tR\bpassword\"*\n" + - "\x14PasswordHashResponse\x12\x12\n" + - "\x04hash\x18\x01 \x01(\tR\x04hash\"\x0f\n" + - "\rLogoutRequest\"\x10\n" + - "\x0eLogoutResponse\"\x17\n" + - "\x15GetDiagnosticsRequest\"b\n" + - "\x16GetDiagnosticsResponse\x12 \n" + - "\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" + - "\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" + - "\vInitRequest\"\xa2\b\n" + - "\fInitResponse\x12\x1e\n" + - "\n" + - "showFooter\x18\x01 \x01(\bR\n" + - "showFooter\x12&\n" + - "\x0eshowNavigation\x18\x02 \x01(\bR\x0eshowNavigation\x12(\n" + - "\x0fshowNewVersions\x18\x03 \x01(\bR\x0fshowNewVersions\x12*\n" + - "\x10availableVersion\x18\x04 \x01(\tR\x10availableVersion\x12&\n" + - "\x0ecurrentVersion\x18\x05 \x01(\tR\x0ecurrentVersion\x12\x1c\n" + - "\tpageTitle\x18\x06 \x01(\tR\tpageTitle\x126\n" + - "\x16sectionNavigationStyle\x18\a \x01(\tR\x16sectionNavigationStyle\x12.\n" + - "\x12defaultIconForBack\x18\b \x01(\tR\x12defaultIconForBack\x12&\n" + - "\x0eenableCustomJs\x18\t \x01(\bR\x0eenableCustomJs\x12\"\n" + - "\fauthLoginUrl\x18\n" + - " \x01(\tR\fauthLoginUrl\x12&\n" + - "\x0eauthLocalLogin\x18\v \x01(\bR\x0eauthLocalLogin\x12\x1c\n" + - "\tstyleMods\x18\f \x03(\tR\tstyleMods\x12I\n" + - "\x0foAuth2Providers\x18\r \x03(\v2\x1f.olivetin.api.v1.OAuth2ProviderR\x0foAuth2Providers\x12I\n" + - "\x0fadditionalLinks\x18\x0e \x03(\v2\x1f.olivetin.api.v1.AdditionalLinkR\x0fadditionalLinks\x12&\n" + - "\x0erootDashboards\x18\x0f \x03(\tR\x0erootDashboards\x12-\n" + - "\x12authenticated_user\x18\x10 \x01(\tR\x11authenticatedUser\x12>\n" + - "\x1bauthenticated_user_provider\x18\x11 \x01(\tR\x19authenticatedUserProvider\x12K\n" + - "\x10effective_policy\x18\x12 \x01(\v2 .olivetin.api.v1.EffectivePolicyR\x0feffectivePolicy\x12%\n" + - "\x0ebanner_message\x18\x13 \x01(\tR\rbannerMessage\x12\x1d\n" + - "\n" + - "banner_css\x18\x14 \x01(\tR\tbannerCss\x12)\n" + - "\x10show_diagnostics\x18\x15 \x01(\bR\x0fshowDiagnostics\x12\"\n" + - "\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\x12%\n" + - "\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\"8\n" + - "\x0eAdditionalLink\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" + - "\x03url\x18\x02 \x01(\tR\x03url\"L\n" + - "\x0eOAuth2Provider\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x12\x12\n" + - "\x04icon\x18\x03 \x01(\tR\x04icon\x12\x10\n" + - "\x03key\x18\x04 \x01(\tR\x03key\"8\n" + - "\x17GetActionBindingRequest\x12\x1d\n" + - "\n" + - "binding_id\x18\x01 \x01(\tR\tbindingId\"K\n" + - "\x18GetActionBindingResponse\x12/\n" + - "\x06action\x18\x01 \x01(\v2\x17.olivetin.api.v1.ActionR\x06action\"\x14\n" + - "\x12GetEntitiesRequest\"g\n" + - "\x13GetEntitiesResponse\x12P\n" + - "\x12entity_definitions\x18\x01 \x03(\v2!.olivetin.api.v1.EntityDefinitionR\x11entityDefinitions\"\x8d\x01\n" + - "\x10EntityDefinition\x12\x14\n" + - "\x05title\x18\x01 \x01(\tR\x05title\x125\n" + - "\tinstances\x18\x02 \x03(\v2\x17.olivetin.api.v1.EntityR\tinstances\x12,\n" + - "\x12used_on_dashboards\x18\x03 \x03(\tR\x10usedOnDashboards\"E\n" + - "\x10GetEntityRequest\x12\x1d\n" + - "\n" + - "unique_key\x18\x01 \x01(\tR\tuniqueKey\x12\x12\n" + - "\x04type\x18\x02 \x01(\tR\x04type\"J\n" + - "\x14RestartActionRequest\x122\n" + - "\x15execution_tracking_id\x18\x01 \x01(\tR\x13executionTrackingId2\xe8\x12\n" + - "\x12OliveTinApiService\x12]\n" + - "\fGetDashboard\x12$.olivetin.api.v1.GetDashboardRequest\x1a%.olivetin.api.v1.GetDashboardResponse\"\x00\x12Z\n" + - "\vStartAction\x12#.olivetin.api.v1.StartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12o\n" + - "\x12StartActionAndWait\x12*.olivetin.api.v1.StartActionAndWaitRequest\x1a+.olivetin.api.v1.StartActionAndWaitResponse\"\x00\x12i\n" + - "\x10StartActionByGet\x12(.olivetin.api.v1.StartActionByGetRequest\x1a).olivetin.api.v1.StartActionByGetResponse\"\x00\x12~\n" + - "\x17StartActionByGetAndWait\x12/.olivetin.api.v1.StartActionByGetAndWaitRequest\x1a0.olivetin.api.v1.StartActionByGetAndWaitResponse\"\x00\x12^\n" + - "\rRestartAction\x12%.olivetin.api.v1.RestartActionRequest\x1a$.olivetin.api.v1.StartActionResponse\"\x00\x12W\n" + - "\n" + - "KillAction\x12\".olivetin.api.v1.KillActionRequest\x1a#.olivetin.api.v1.KillActionResponse\"\x00\x12f\n" + - "\x0fExecutionStatus\x12'.olivetin.api.v1.ExecutionStatusRequest\x1a(.olivetin.api.v1.ExecutionStatusResponse\"\x00\x12N\n" + - "\aGetLogs\x12\x1f.olivetin.api.v1.GetLogsRequest\x1a .olivetin.api.v1.GetLogsResponse\"\x00\x12`\n" + - "\rGetActionLogs\x12%.olivetin.api.v1.GetActionLogsRequest\x1a&.olivetin.api.v1.GetActionLogsResponse\"\x00\x12u\n" + - "\x14ValidateArgumentType\x12,.olivetin.api.v1.ValidateArgumentTypeRequest\x1a-.olivetin.api.v1.ValidateArgumentTypeResponse\"\x00\x12K\n" + - "\x06WhoAmI\x12\x1e.olivetin.api.v1.WhoAmIRequest\x1a\x1f.olivetin.api.v1.WhoAmIResponse\"\x00\x12T\n" + - "\tSosReport\x12!.olivetin.api.v1.SosReportRequest\x1a\".olivetin.api.v1.SosReportResponse\"\x00\x12Q\n" + - "\bDumpVars\x12 .olivetin.api.v1.DumpVarsRequest\x1a!.olivetin.api.v1.DumpVarsResponse\"\x00\x12x\n" + - "\x15DumpPublicIdActionMap\x12-.olivetin.api.v1.DumpPublicIdActionMapRequest\x1a..olivetin.api.v1.DumpPublicIdActionMapResponse\"\x00\x12T\n" + - "\tGetReadyz\x12!.olivetin.api.v1.GetReadyzRequest\x1a\".olivetin.api.v1.GetReadyzResponse\"\x00\x12c\n" + - "\x0eLocalUserLogin\x12&.olivetin.api.v1.LocalUserLoginRequest\x1a'.olivetin.api.v1.LocalUserLoginResponse\"\x00\x12]\n" + - "\fPasswordHash\x12$.olivetin.api.v1.PasswordHashRequest\x1a%.olivetin.api.v1.PasswordHashResponse\"\x00\x12K\n" + - "\x06Logout\x12\x1e.olivetin.api.v1.LogoutRequest\x1a\x1f.olivetin.api.v1.LogoutResponse\"\x00\x12\\\n" + - "\vEventStream\x12#.olivetin.api.v1.EventStreamRequest\x1a$.olivetin.api.v1.EventStreamResponse\"\x000\x01\x12c\n" + - "\x0eGetDiagnostics\x12&.olivetin.api.v1.GetDiagnosticsRequest\x1a'.olivetin.api.v1.GetDiagnosticsResponse\"\x00\x12E\n" + - "\x04Init\x12\x1c.olivetin.api.v1.InitRequest\x1a\x1d.olivetin.api.v1.InitResponse\"\x00\x12i\n" + - "\x10GetActionBinding\x12(.olivetin.api.v1.GetActionBindingRequest\x1a).olivetin.api.v1.GetActionBindingResponse\"\x00\x12Z\n" + - "\vGetEntities\x12#.olivetin.api.v1.GetEntitiesRequest\x1a$.olivetin.api.v1.GetEntitiesResponse\"\x00\x12I\n" + - "\tGetEntity\x12!.olivetin.api.v1.GetEntityRequest\x1a\x17.olivetin.api.v1.Entity\"\x00B8Z6github.com/OliveTin/OliveTin/gen/olivetin/api/v1;apiv1b\x06proto3" - -var ( - file_olivetin_api_v1_olivetin_proto_rawDescOnce sync.Once - file_olivetin_api_v1_olivetin_proto_rawDescData []byte -) - -func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte { - file_olivetin_api_v1_olivetin_proto_rawDescOnce.Do(func() { - file_olivetin_api_v1_olivetin_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc))) - }) - return file_olivetin_api_v1_olivetin_proto_rawDescData -} - -var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 72) -var file_olivetin_api_v1_olivetin_proto_goTypes = []any{ - (*Action)(nil), // 0: olivetin.api.v1.Action - (*ActionArgument)(nil), // 1: olivetin.api.v1.ActionArgument - (*ActionArgumentChoice)(nil), // 2: olivetin.api.v1.ActionArgumentChoice - (*Entity)(nil), // 3: olivetin.api.v1.Entity - (*GetDashboardResponse)(nil), // 4: olivetin.api.v1.GetDashboardResponse - (*EffectivePolicy)(nil), // 5: olivetin.api.v1.EffectivePolicy - (*GetDashboardRequest)(nil), // 6: olivetin.api.v1.GetDashboardRequest - (*Dashboard)(nil), // 7: olivetin.api.v1.Dashboard - (*DashboardComponent)(nil), // 8: olivetin.api.v1.DashboardComponent - (*StartActionRequest)(nil), // 9: olivetin.api.v1.StartActionRequest - (*StartActionArgument)(nil), // 10: olivetin.api.v1.StartActionArgument - (*StartActionResponse)(nil), // 11: olivetin.api.v1.StartActionResponse - (*StartActionAndWaitRequest)(nil), // 12: olivetin.api.v1.StartActionAndWaitRequest - (*StartActionAndWaitResponse)(nil), // 13: olivetin.api.v1.StartActionAndWaitResponse - (*StartActionByGetRequest)(nil), // 14: olivetin.api.v1.StartActionByGetRequest - (*StartActionByGetResponse)(nil), // 15: olivetin.api.v1.StartActionByGetResponse - (*StartActionByGetAndWaitRequest)(nil), // 16: olivetin.api.v1.StartActionByGetAndWaitRequest - (*StartActionByGetAndWaitResponse)(nil), // 17: olivetin.api.v1.StartActionByGetAndWaitResponse - (*GetLogsRequest)(nil), // 18: olivetin.api.v1.GetLogsRequest - (*LogEntry)(nil), // 19: olivetin.api.v1.LogEntry - (*GetLogsResponse)(nil), // 20: olivetin.api.v1.GetLogsResponse - (*GetActionLogsRequest)(nil), // 21: olivetin.api.v1.GetActionLogsRequest - (*GetActionLogsResponse)(nil), // 22: olivetin.api.v1.GetActionLogsResponse - (*ValidateArgumentTypeRequest)(nil), // 23: olivetin.api.v1.ValidateArgumentTypeRequest - (*ValidateArgumentTypeResponse)(nil), // 24: olivetin.api.v1.ValidateArgumentTypeResponse - (*WatchExecutionRequest)(nil), // 25: olivetin.api.v1.WatchExecutionRequest - (*WatchExecutionUpdate)(nil), // 26: olivetin.api.v1.WatchExecutionUpdate - (*ExecutionStatusRequest)(nil), // 27: olivetin.api.v1.ExecutionStatusRequest - (*ExecutionStatusResponse)(nil), // 28: olivetin.api.v1.ExecutionStatusResponse - (*WhoAmIRequest)(nil), // 29: olivetin.api.v1.WhoAmIRequest - (*WhoAmIResponse)(nil), // 30: olivetin.api.v1.WhoAmIResponse - (*SosReportRequest)(nil), // 31: olivetin.api.v1.SosReportRequest - (*SosReportResponse)(nil), // 32: olivetin.api.v1.SosReportResponse - (*DumpVarsRequest)(nil), // 33: olivetin.api.v1.DumpVarsRequest - (*DumpVarsResponse)(nil), // 34: olivetin.api.v1.DumpVarsResponse - (*DebugBinding)(nil), // 35: olivetin.api.v1.DebugBinding - (*DumpPublicIdActionMapRequest)(nil), // 36: olivetin.api.v1.DumpPublicIdActionMapRequest - (*DumpPublicIdActionMapResponse)(nil), // 37: olivetin.api.v1.DumpPublicIdActionMapResponse - (*GetReadyzRequest)(nil), // 38: olivetin.api.v1.GetReadyzRequest - (*GetReadyzResponse)(nil), // 39: olivetin.api.v1.GetReadyzResponse - (*EventStreamRequest)(nil), // 40: olivetin.api.v1.EventStreamRequest - (*EventStreamResponse)(nil), // 41: olivetin.api.v1.EventStreamResponse - (*EventOutputChunk)(nil), // 42: olivetin.api.v1.EventOutputChunk - (*EventEntityChanged)(nil), // 43: olivetin.api.v1.EventEntityChanged - (*EventConfigChanged)(nil), // 44: olivetin.api.v1.EventConfigChanged - (*EventExecutionFinished)(nil), // 45: olivetin.api.v1.EventExecutionFinished - (*EventExecutionStarted)(nil), // 46: olivetin.api.v1.EventExecutionStarted - (*KillActionRequest)(nil), // 47: olivetin.api.v1.KillActionRequest - (*KillActionResponse)(nil), // 48: olivetin.api.v1.KillActionResponse - (*LocalUserLoginRequest)(nil), // 49: olivetin.api.v1.LocalUserLoginRequest - (*LocalUserLoginResponse)(nil), // 50: olivetin.api.v1.LocalUserLoginResponse - (*PasswordHashRequest)(nil), // 51: olivetin.api.v1.PasswordHashRequest - (*PasswordHashResponse)(nil), // 52: olivetin.api.v1.PasswordHashResponse - (*LogoutRequest)(nil), // 53: olivetin.api.v1.LogoutRequest - (*LogoutResponse)(nil), // 54: olivetin.api.v1.LogoutResponse - (*GetDiagnosticsRequest)(nil), // 55: olivetin.api.v1.GetDiagnosticsRequest - (*GetDiagnosticsResponse)(nil), // 56: olivetin.api.v1.GetDiagnosticsResponse - (*InitRequest)(nil), // 57: olivetin.api.v1.InitRequest - (*InitResponse)(nil), // 58: olivetin.api.v1.InitResponse - (*AdditionalLink)(nil), // 59: olivetin.api.v1.AdditionalLink - (*OAuth2Provider)(nil), // 60: olivetin.api.v1.OAuth2Provider - (*GetActionBindingRequest)(nil), // 61: olivetin.api.v1.GetActionBindingRequest - (*GetActionBindingResponse)(nil), // 62: olivetin.api.v1.GetActionBindingResponse - (*GetEntitiesRequest)(nil), // 63: olivetin.api.v1.GetEntitiesRequest - (*GetEntitiesResponse)(nil), // 64: olivetin.api.v1.GetEntitiesResponse - (*EntityDefinition)(nil), // 65: olivetin.api.v1.EntityDefinition - (*GetEntityRequest)(nil), // 66: olivetin.api.v1.GetEntityRequest - (*RestartActionRequest)(nil), // 67: olivetin.api.v1.RestartActionRequest - nil, // 68: olivetin.api.v1.ActionArgument.SuggestionsEntry - nil, // 69: olivetin.api.v1.Entity.FieldsEntry - nil, // 70: olivetin.api.v1.DumpVarsResponse.ContentsEntry - nil, // 71: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry -} -var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{ - 1, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument - 2, // 1: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice - 68, // 2: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry - 69, // 3: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry - 7, // 4: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard - 8, // 5: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent - 8, // 6: olivetin.api.v1.DashboardComponent.contents:type_name -> olivetin.api.v1.DashboardComponent - 0, // 7: olivetin.api.v1.DashboardComponent.action:type_name -> olivetin.api.v1.Action - 10, // 8: olivetin.api.v1.StartActionRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument - 10, // 9: olivetin.api.v1.StartActionAndWaitRequest.arguments:type_name -> olivetin.api.v1.StartActionArgument - 19, // 10: olivetin.api.v1.StartActionAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 11: olivetin.api.v1.StartActionByGetAndWaitResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 12: olivetin.api.v1.GetLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry - 19, // 13: olivetin.api.v1.GetActionLogsResponse.logs:type_name -> olivetin.api.v1.LogEntry - 19, // 14: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry - 70, // 15: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry - 71, // 16: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry - 43, // 17: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged - 44, // 18: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged - 45, // 19: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished - 46, // 20: olivetin.api.v1.EventStreamResponse.execution_started:type_name -> olivetin.api.v1.EventExecutionStarted - 42, // 21: olivetin.api.v1.EventStreamResponse.output_chunk:type_name -> olivetin.api.v1.EventOutputChunk - 19, // 22: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry - 19, // 23: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry - 60, // 24: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider - 59, // 25: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink - 5, // 26: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy - 0, // 27: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action - 65, // 28: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition - 3, // 29: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity - 35, // 30: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding - 6, // 31: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest - 9, // 32: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest - 12, // 33: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest - 14, // 34: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest - 16, // 35: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest - 67, // 36: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest - 47, // 37: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest - 27, // 38: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest - 18, // 39: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest - 21, // 40: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest - 23, // 41: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest - 29, // 42: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest - 31, // 43: olivetin.api.v1.OliveTinApiService.SosReport:input_type -> olivetin.api.v1.SosReportRequest - 33, // 44: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest - 36, // 45: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest - 38, // 46: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest - 49, // 47: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest - 51, // 48: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest - 53, // 49: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest - 40, // 50: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest - 55, // 51: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest - 57, // 52: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest - 61, // 53: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest - 63, // 54: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest - 66, // 55: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest - 4, // 56: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse - 11, // 57: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse - 13, // 58: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse - 15, // 59: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse - 17, // 60: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse - 11, // 61: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse - 48, // 62: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse - 28, // 63: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse - 20, // 64: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse - 22, // 65: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse - 24, // 66: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse - 30, // 67: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse - 32, // 68: olivetin.api.v1.OliveTinApiService.SosReport:output_type -> olivetin.api.v1.SosReportResponse - 34, // 69: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse - 37, // 70: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse - 39, // 71: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse - 50, // 72: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse - 52, // 73: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse - 54, // 74: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse - 41, // 75: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse - 56, // 76: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse - 58, // 77: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse - 62, // 78: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse - 64, // 79: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse - 3, // 80: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity - 56, // [56:81] is the sub-list for method output_type - 31, // [31:56] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name -} - -func init() { file_olivetin_api_v1_olivetin_proto_init() } -func file_olivetin_api_v1_olivetin_proto_init() { - if File_olivetin_api_v1_olivetin_proto != nil { - return - } - file_olivetin_api_v1_olivetin_proto_msgTypes[41].OneofWrappers = []any{ - (*EventStreamResponse_EntityChanged)(nil), - (*EventStreamResponse_ConfigChanged)(nil), - (*EventStreamResponse_ExecutionFinished)(nil), - (*EventStreamResponse_ExecutionStarted)(nil), - (*EventStreamResponse_OutputChunk)(nil), - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)), - NumEnums: 0, - NumMessages: 72, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_olivetin_api_v1_olivetin_proto_goTypes, - DependencyIndexes: file_olivetin_api_v1_olivetin_proto_depIdxs, - MessageInfos: file_olivetin_api_v1_olivetin_proto_msgTypes, - }.Build() - File_olivetin_api_v1_olivetin_proto = out.File - file_olivetin_api_v1_olivetin_proto_goTypes = nil - file_olivetin_api_v1_olivetin_proto_depIdxs = nil -} diff --git a/service/go.mod b/service/go.mod index 659b3ab..28ccb85 100644 --- a/service/go.mod +++ b/service/go.mod @@ -1,35 +1,35 @@ module github.com/OliveTin/OliveTin -go 1.25.0 +go 1.25.10 exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884 require ( - connectrpc.com/connect v1.19.1 + connectrpc.com/connect v1.20.0 github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc/v3 v3.8.0 github.com/PaesslerAG/jsonpath v0.1.1 github.com/alexedwards/argon2id v1.0.0 - github.com/bufbuild/buf v1.65.0 - github.com/fsnotify/fsnotify v1.9.0 + github.com/bufbuild/buf v1.70.0 + github.com/fsnotify/fsnotify v1.10.1 github.com/fzipp/gocyclo v0.6.0 github.com/go-critic/go-critic v0.14.3 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac + github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/providers/rawbytes v1.0.0 - github.com/knadh/koanf/v2 v2.3.2 + github.com/knadh/koanf/v2 v2.3.4 github.com/prometheus/client_golang v1.23.2 github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 go.akshayshah.org/connectproto v0.6.0 - golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a - golang.org/x/oauth2 v0.35.0 - golang.org/x/sys v0.41.0 + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a + golang.org/x/oauth2 v0.36.0 + golang.org/x/sys v0.45.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -37,19 +37,19 @@ require ( require ( buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1 // indirect buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 // indirect - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect - buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 // indirect - buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1 // indirect + buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260507063250-43b0c5a6cd08.1 // indirect buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 // indirect - buf.build/go/app v0.2.0 // indirect - buf.build/go/bufplugin v0.9.0 // indirect + buf.build/go/app v0.2.1-0.20260407195847-833f8f978cda // indirect + buf.build/go/bufplugin v0.10.0 // indirect buf.build/go/bufprivateusage v0.1.0 // indirect buf.build/go/interrupt v1.1.0 // indirect - buf.build/go/protovalidate v1.1.2 // indirect - buf.build/go/protoyaml v0.6.0 // indirect + buf.build/go/protovalidate v1.2.0 // indirect + buf.build/go/protoyaml v0.7.0 // indirect buf.build/go/spdx v0.2.0 // indirect - buf.build/go/standard v0.1.0 // indirect - cel.dev/expr v0.25.1 // indirect + buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect + cel.dev/expr v0.25.2 // indirect connectrpc.com/otelconnect v0.9.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/MicahParks/jwkset v0.11.0 // indirect @@ -57,8 +57,8 @@ require ( github.com/PaesslerAG/gval v1.2.4 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e // indirect - github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect + github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132 // indirect + github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -68,12 +68,13 @@ require ( github.com/cristalhq/acmd v0.12.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/cli v29.2.1+incompatible // indirect + github.com/docker/cli v29.5.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.5 // indirect - github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/docker-credential-helpers v0.9.7 // indirect + github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/expr-lang/expr v1.17.8 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -88,76 +89,78 @@ require ( github.com/go-toolsmith/typep v1.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofrs/flock v0.13.0 // indirect - github.com/google/cel-go v0.27.0 // indirect + github.com/google/cel-go v0.28.1 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/go-containerregistry v0.20.7 // indirect + github.com/google/go-containerregistry v0.21.6 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jdx/go-netrc v1.0.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect github.com/quasilyte/gogrep v0.5.0 // indirect github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/cors v1.11.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.1 // indirect - github.com/segmentio/encoding v0.5.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/btree v1.8.1 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect + github.com/vbatts/tar-split v0.12.3 // indirect go.lsp.dev/jsonrpc2 v0.10.0 // indirect go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect go.lsp.dev/protocol v0.12.0 // indirect go.lsp.dev/uri v0.3.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp/typeparams v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/grpc v1.75.1 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.45.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/grpc v1.79.3 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect pluginrpc.com/pluginrpc v0.5.0 // indirect ) diff --git a/service/go.sum b/service/go.sum index 5ef6552..b5d25c5 100644 --- a/service/go.sum +++ b/service/go.sum @@ -6,24 +6,36 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202512091757 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20251202164234-62b14f0b533c.2 h1:eQ6XRVUaYYZFOZvBsyrOYLWbw6464s5dVnHscxa0b8w= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20251202164234-62b14f0b533c.2/go.mod h1:omxVRch3jEPMINnUipLsuRWoEhND6LPXELKBG7xzyDw= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260122161138-ab4e39a3c3bc.2 h1:cMzWbIukJ5uk1M58CtqmBE7Ojacg/t2nAg4AbS78uX8= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260122161138-ab4e39a3c3bc.2/go.mod h1:GL3rFhQQsaI3PCBa0y5X71UHs6q5E/Xf9Q8WXBxE7a8= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 h1:XPrWCd9ydEo5Ofv1aNJVJaxndMXLQjRO9vVzsJG3jL8= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2/go.mod h1:mpsjeEaxOYPIJV2cz4IagLghZufRvx+NPVtInjEeoQ8= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.2-20260507063250-43b0c5a6cd08.1 h1:DcwtSdaY9CwXwPSOneDxJ/B0OCAgNPQQaQxAr/pTHvc= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.2-20260507063250-43b0c5a6cd08.1/go.mod h1:WjOwVG7wzFSwEkjCjHVRWEOdGYyON/TQYPabl7N2VGI= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1 h1:f8pa4iy1Bs+hQ16f3jg22rV/StDKIRj1rNNWX5rLwZ8= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1/go.mod h1:7MNigA51XJjPKrLnbcE61BmgW+pAp3mLW61gUqLBBRY= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20251202164234-62b14f0b533c.1 h1:PdfIJUbUVKdajMVYuMdvr2Wvo+wmzGnlPEYA4bhFaWI= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20251202164234-62b14f0b533c.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260122161138-ab4e39a3c3bc.1 h1:yWmrELGX6l1GphG9kPVcrMQLjWfXGI5bLDxwE+SfbDw= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260122161138-ab4e39a3c3bc.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 h1:Yreby6Ypa58wdQUEm9Fnc5g8n/jP487Dq3aK5yBYwfk= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260507063250-43b0c5a6cd08.1 h1:QK2GkcPxqh2oG5mTMAHejculun8nxto+p7mlgh8fPTM= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260507063250-43b0c5a6cd08.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= buf.build/go/app v0.2.0 h1:NYaH13A+RzPb7M5vO8uZYZ2maBZI5+MS9A9tQm66fy8= buf.build/go/app v0.2.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= +buf.build/go/app v0.2.1-0.20260407195847-833f8f978cda h1:eysSyjrJtkxU1A/9+Kv+1Mwq9K6BYBw+STIOVsZ256Y= +buf.build/go/app v0.2.1-0.20260407195847-833f8f978cda/go.mod h1:V32mBaPWsfq6REAeZvvs/rQl7ZCl9Dn7eW1BBrmH0GQ= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= +buf.build/go/bufplugin v0.10.0 h1:vZBX0mq9as5UIBug8U+/DkGRaHNlM/HVOw59O8fvOIU= +buf.build/go/bufplugin v0.10.0/go.mod h1:ax7obVurKDH1I2nR4pFTS+TE6K3kZhTmwDCN2YgdV8I= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w= buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE= @@ -32,16 +44,30 @@ buf.build/go/protovalidate v1.1.0 h1:pQqEQRpOo4SqS60qkvmhLTTQU9JwzEvdyiqAtXa5SeY buf.build/go/protovalidate v1.1.0/go.mod h1:bGZcPiAQDC3ErCHK3t74jSoJDFOs2JH3d7LWuTEIdss= buf.build/go/protovalidate v1.1.2 h1:83vYHoY8f34hB8MeitGaYE3CGVPFxwdEUuskh5qQpA0= buf.build/go/protovalidate v1.1.2/go.mod h1:Ez3z+w4c+wG+EpW8ovgZaZPnPl2XVF6kaxgcv1NG/QE= +buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= +buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= +buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= +buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= +buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= buf.build/go/standard v0.1.0 h1:g98T9IyvAl0vS3Pq8iVk6Cvj2ZiFvoUJRtfyGa0120U= buf.build/go/standard v0.1.0/go.mod h1:PiqpHz/7ZFq+kqvYhc/SK3lxFIB9N/aiH2CFC2JHIQg= +buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 h1:njYKSWoLiq2i5O7y2bPPU2Yzp7iAU0Wk9KJ2OoAhNiU= +buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5/go.mod h1:DQmodNT9EHX94WzUaWiZK+/4EaFa/xZTc1gzfCxZVXU= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= +connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= +connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= connectrpc.com/otelconnect v0.8.0 h1:a4qrN4H8aEE2jAoCxheZYYfEjXMgVPyL9OzPQLBEFXU= connectrpc.com/otelconnect v0.8.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= @@ -84,6 +110,14 @@ github.com/bufbuild/buf v1.64.0 h1:puHWFcVKmZFSu4KuaN0kZiQ32n7VVc3un1FeLU77XUs= github.com/bufbuild/buf v1.64.0/go.mod h1:U4ISwkjZXRLMaCkPG9zp1xY3xHEIwhCFwyNAaA56SGw= github.com/bufbuild/buf v1.65.0 h1:f2BzeCY9rRh9P5KD340ZoPAaFLTkssoUTHx7lpqozgg= github.com/bufbuild/buf v1.65.0/go.mod h1:7SAs2YqGpPXHqBBXBeYQbCzY0OQq4Jbg6XCqirEiYvQ= +github.com/bufbuild/buf v1.66.0 h1:6kksYJpu6r45bvPJSTwNSwRqiAjrwB9YyU7skjNzFVo= +github.com/bufbuild/buf v1.66.0/go.mod h1:tWVlwtIPZ7kzlCB9D0hbbfrroT0GNCybPdPQXq1i1Ac= +github.com/bufbuild/buf v1.66.1 h1:wqmmU+6uoxB/eYDOmXq2To4qEXvOJN7gR6L9AxrPL1E= +github.com/bufbuild/buf v1.66.1/go.mod h1:Vd3ELm8IePWaDJaS9FLy94FFOnLrjLi4mDxmXtw9Xio= +github.com/bufbuild/buf v1.69.0 h1:q1YTnHJISHuoeUdmsuC9u+nb9rV8glM/TOsPNEteEzg= +github.com/bufbuild/buf v1.69.0/go.mod h1:Q3KRCXSanDCMFs2zL/MqUwUQV0OUqs23P2sy58CW0nc= +github.com/bufbuild/buf v1.70.0 h1:rGL4TGoy8F1DbQa4BSlMOVBBR7lWblfnKxUdOxmeFns= +github.com/bufbuild/buf v1.70.0/go.mod h1:5gCCIpDmBzhiSJwqmxmbdN5aRZQYGmFSGnOBE7seP8c= github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e h1:LQA+1MyiPkolGHJGC2GMDC5Xu+0RDVH6jGMKech7Exs= github.com/bufbuild/protocompile v0.14.2-0.20251223142729-db46c1b9d34e/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI= github.com/bufbuild/protocompile v0.14.2-0.20260105175043-4d8d90b1c6b8 h1:cQYwUyAzyMmYr7AyJU1C6pVCpUrJJBkmx7UunZosxxs= @@ -92,8 +126,18 @@ github.com/bufbuild/protocompile v0.14.2-0.20260120135352-a3ed5cd7a608 h1:3aRREB github.com/bufbuild/protocompile v0.14.2-0.20260120135352-a3ed5cd7a608/go.mod h1:5UUj46Eu+U+C59C5N6YilaMI7WWfP2bW9xGcOkme2DI= github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e h1:emH16Bf1w4C0cJ3ge4QtBAl4sIYJe23EfpWH0SpA9co= github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE= +github.com/bufbuild/protocompile v0.14.2-0.20260202185951-d02d3732d113 h1:nxt1QhP9rMQNFhHTdcNFwJ9wKCSdBjd28gz+qGDv4kM= +github.com/bufbuild/protocompile v0.14.2-0.20260202185951-d02d3732d113/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE= +github.com/bufbuild/protocompile v0.14.2-0.20260306221011-519528254156 h1:XOfIInPVufMjifwy3fli8qQVsGHWVCDVY/zp6elAOsY= +github.com/bufbuild/protocompile v0.14.2-0.20260306221011-519528254156/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE= +github.com/bufbuild/protocompile v0.14.2-0.20260429155904-12ef1ef2ce91 h1:RPIMBLTMx/CRy0NVyb6yJDlGx2Vo84FsU+kAh46zqIA= +github.com/bufbuild/protocompile v0.14.2-0.20260429155904-12ef1ef2ce91/go.mod h1:DhgqsRznX/F0sGkUYtTQJRP+q8xMReQRQ3qr+n1opWU= +github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132 h1:f4T4k/41jHHhp2Otl6ZShDedr4wF9b+NdqIfLezx4R4= +github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132/go.mod h1:jPUiZUFWc8E3Kc2Y4SRlGAdjde4amGkHY0BUACNS43E= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= +github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ= +github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -128,6 +172,14 @@ github.com/docker/cli v29.1.5+incompatible h1:GckbANUt3j+lsnQ6eCcQd70mNSOismSHWt github.com/docker/cli v29.1.5+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= +github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.5.1+incompatible h1:NiufLAJoRcPauFoBNYthfuM4REFwM8H2h9xnLABNHGs= +github.com/docker/cli v29.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.5.2+incompatible h1:ubykJ1Y8LmNRGJ2BuMQ0kHOt/RO1YzGNswqWMJgivuQ= +github.com/docker/cli v29.5.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= @@ -136,14 +188,22 @@ github.com/docker/docker-credential-helpers v0.9.4 h1:76ItO69/AP/V4yT9V4uuuItG0B github.com/docker/docker-credential-helpers v0.9.4/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/docker-credential-helpers v0.9.7 h1:jaPIxEIDz5bQeghNAdzz0ETwMMnM4vzjZlxz3pWP4JA= +github.com/docker/docker-credential-helpers v0.9.7/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= +github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= @@ -194,12 +254,24 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-containerregistry v0.21.0 h1:ocqxUOczFwAZQBMNE7kuzfqvDe0VWoZxQMOesXreCDI= +github.com/google/go-containerregistry v0.21.0/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= +github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkYW0zWDLLUXqAi4= +github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -210,6 +282,8 @@ github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0P github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE= github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac h1:JQ6AC9tf2xhwTxMY9nuIeOPM7Cj0BDeCNgKPrNgQvtQ= github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE= +github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da h1:hYsJqujd3A4Xtp9swe2d6Y6ij2ecd16E+i2oHAH/xaA= +github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= @@ -220,6 +294,10 @@ github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+ github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -236,6 +314,8 @@ github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= +github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -246,6 +326,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -254,6 +336,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= +github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -272,6 +358,10 @@ github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a h1:VweslR2akb/ARh github.com/petermattis/goid v0.0.0-20251121121749-a11dd1a45f9a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM= +github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -286,6 +376,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4= github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= @@ -302,6 +394,8 @@ github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4U github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -320,6 +414,8 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -349,6 +445,8 @@ github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vbatts/tar-split v0.12.3 h1:Cd46rkGXI3Td4yrVNwU8ripbxFaQbmesqhjBUUYAJSw= +github.com/vbatts/tar-split v0.12.3/go.mod h1:sQOc6OlqGCr7HkGx/IDBeKiTIvqhmj8KffNhEXG4Nq0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.akshayshah.org/attest v1.0.0 h1:RVGitcLbAO5i4PIJJDztZ/E9qQ8VSp1PS5PnR4Btg0c= go.akshayshah.org/attest v1.0.0/go.mod h1:PnWzcW5j9dkyGwTlBmUsYpPnHG0AUPrs1RQ+HrldWO0= @@ -368,29 +466,51 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= @@ -402,8 +522,12 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -415,12 +539,24 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= +golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20251219203646-944ab1f22d93 h1:PbC785RGO6yPO051ItgbG/adwoKRWC0VS7kXXeD/iqk= @@ -429,6 +565,10 @@ golang.org/x/exp/typeparams v0.0.0-20260112195511-716be5621a96 h1:RMc8anw0hCPcg5 golang.org/x/exp/typeparams v0.0.0-20260112195511-716be5621a96/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/exp/typeparams v0.0.0-20260212183809-81e46e3db34a h1:n3SZDk8iNpMasCwQD7/0dIaCVf3gJiGZ9Rqa094jUN0= golang.org/x/exp/typeparams v0.0.0-20260212183809-81e46e3db34a/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90 h1:cfW8UCYSVdPblxA7qQe3o5Iad55Vsx4BFmuGS9RNOmc= +golang.org/x/exp/typeparams v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= +golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a h1:H06+n8uULVXJdhbdJ9+40jLzRcAPQP2h1UXcs01jzsk= +golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= @@ -437,6 +577,10 @@ golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -448,15 +592,25 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -474,6 +628,12 @@ golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -485,6 +645,10 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -497,8 +661,14 @@ golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -509,6 +679,10 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E= @@ -519,6 +693,16 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d h1: google.golang.org/genproto/googleapis/api v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA= +google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw= +google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= +google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 h1:WVVw1Nl19li0fMX++FJ3ye1z9+S1N35QODDy5qpnaXw= +google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= @@ -527,8 +711,20 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260122232226-8e98ce8d340d/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/service/internal/api/api.go b/service/internal/api/api.go index faf58d1..5c3d240 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -3,6 +3,7 @@ package api import ( ctx "context" "encoding/json" + "errors" "os" "path" "sort" @@ -57,6 +58,45 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient { type streamingClient struct { channel chan *apiv1.EventStreamResponse AuthenticatedUser *authpublic.AuthenticatedUser + heartbeatStopOnce sync.Once + heartbeatStop chan struct{} + heartbeatDone chan struct{} +} + +func (c *streamingClient) stopHeartbeat() { + if c.heartbeatStop == nil || c.heartbeatDone == nil { + return + } + c.heartbeatStopOnce.Do(func() { + close(c.heartbeatStop) + }) + <-c.heartbeatDone +} + +// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed. +func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool { + if client == nil || msg == nil { + return false + } + sent := sendToStreamingClientChannel(client.channel, msg) + if !sent { + log.Warnf("EventStream: client channel is full or closed, removing client") + } + return sent +} + +func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) { + defer func() { + if recover() != nil { + sent = false + } + }() + select { + case ch <- msg: + return true + default: + return false + } } func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) { @@ -69,20 +109,21 @@ func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.K execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId) if !ret.Found { - log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId) - return connect.NewResponse(ret), nil + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId)) } - log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId) + if execReqLogEntry.Binding == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId)) + } action := execReqLogEntry.Binding.Action if action == nil { - log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle) - ret.Killed = false - return connect.NewResponse(ret), nil + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId)) } + log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId) + user := auth.UserFromApiCall(ctx, req, api.cfg) api.killActionByTrackingId(user, action, execReqLogEntry, ret) @@ -109,41 +150,39 @@ func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUse } func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) { - args := make(map[string]string) - - for _, arg := range req.Msg.Arguments { - args[arg.Name] = arg.Value - } - - pair := api.executor.FindBindingByID(req.Msg.BindingId) - - if pair == nil || pair.Action == nil { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId)) + pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId) + if err != nil { + return nil, err } authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) + if err := validateJustificationRequired(pair.Action, req.Msg.Justification, authenticatedUser); err != nil { + return nil, connectInvalidJustification(err) + } execReq := executor.ExecutionRequest{ Binding: pair, TrackingID: req.Msg.UniqueTrackingId, - Arguments: args, + Arguments: startActionArgumentsFromProto(req.Msg.Arguments), + Justification: req.Msg.Justification, AuthenticatedUser: authenticatedUser, Cfg: api.cfg, } api.executor.ExecRequest(&execReq) - ret := &apiv1.StartActionResponse{ + return connect.NewResponse(&apiv1.StartActionResponse{ ExecutionTrackingId: execReq.TrackingID, - } - - return connect.NewResponse(ret), nil + }), nil } func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) { hash, err := createHash(req.Msg.Password) if err != nil { + if errors.Is(err, ErrArgon2Busy) { + return nil, connect.NewError(connect.CodeResourceExhausted, err) + } return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err)) } @@ -154,91 +193,123 @@ func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1 return connect.NewResponse(ret), nil } -func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) { - // Check if local user authentication is enabled - if !api.cfg.AuthLocalUsers.Enabled { - return connect.NewResponse(&apiv1.LocalUserLoginResponse{ - Success: false, - }), nil - } - - match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password) - - response := connect.NewResponse(&apiv1.LocalUserLoginResponse{ - Success: match, - }) +func (api *oliveTinAPI) cookieSecure(header http.Header) bool { + useTLS := header.Get("X-Forwarded-Proto") == "https" + return useTLS || api.cfg.Security.ForceSecureCookies +} +func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, response *connect.Response[apiv1.LocalUserLoginResponse], match bool, secure bool) { if match { - // Set authentication cookie for successful login - user := api.cfg.FindUserByUsername(req.Msg.Username) + user := api.cfg.FindUserByUsername(req.Username) if user != nil { sid := uuid.NewString() - // Register the session in the session storage auth.RegisterUserSession(api.cfg, "local", sid, user.Username) - - log.WithFields(log.Fields{ - "username": user.Username, - }).Info("LocalUserLogin: Session created and registered") - - // Set the authentication cookie in the response headers + log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: Session created and registered") cookie := &http.Cookie{ Name: "olivetin-sid-local", Value: sid, - MaxAge: 31556952, // 1 year + MaxAge: 31556952, HttpOnly: true, Path: "/", + Secure: secure, + SameSite: http.SameSiteLaxMode, } response.Header().Set("Set-Cookie", cookie.String()) + log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: User logged in successfully.") + } else { + log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: Password matched but user lookup failed.") } - - log.WithFields(log.Fields{ - "username": req.Msg.Username, - }).Info("LocalUserLogin: User logged in successfully.") } else { - log.WithFields(log.Fields{ - "username": req.Msg.Username, - }).Warn("LocalUserLogin: User login failed.") + log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: User login failed.") + } +} + +func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] { + if !api.cfg.AuthLocalUsers.Enabled { + return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}) } + if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) { + log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)") + return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}) + } + + return nil +} + +func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) { + if early := api.localUserLoginEarlyReject(req); early != nil { + return early, nil + } + + match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password) + if err != nil { + if errors.Is(err, ErrArgon2Busy) { + return nil, connect.NewError(connect.CodeResourceExhausted, err) + } + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("checking password: %w", err)) + } + response := connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: match}) + api.applyLocalLoginResult(req.Msg, response, match, api.cookieSecure(req.Header())) return response, nil } -func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) { - args := make(map[string]string) - - for _, arg := range req.Msg.Arguments { - args[arg.Name] = arg.Value - } - - user := auth.UserFromApiCall(ctx, req, api.cfg) - +func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) { execReq := executor.ExecutionRequest{ - Binding: api.executor.FindBindingByID(req.Msg.ActionId), + Binding: binding, TrackingID: uuid.NewString(), Arguments: args, + Justification: justification, AuthenticatedUser: user, Cfg: api.cfg, } - wg, _ := api.executor.ExecRequest(&execReq) wg.Wait() + return api.executor.GetLog(execReq.TrackingID) +} - internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID) - - if ok { - return connect.NewResponse(&apiv1.StartActionAndWaitResponse{ - LogEntry: api.internalLogEntryToPb(internalLogEntry, user), - }), nil - } else { - return nil, fmt.Errorf("execution not found") +func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.ActionBinding, error) { + binding := api.executor.FindBindingByID(actionId) + if binding == nil || binding.Action == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", actionId)) } + return binding, nil +} + +func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) { + return api.findBindingOrNotFound(bindingId) +} + +func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) { + binding, err := api.findBindingOrNotFound(req.Msg.ActionId) + if err != nil { + return nil, err + } + + user := auth.UserFromApiCall(ctx, req, api.cfg) + if err := validateJustificationRequired(binding.Action, req.Msg.Justification, user); err != nil { + return nil, connectInvalidJustification(err) + } + + internalLogEntry, ok := api.startActionAndWaitRun(binding, startActionArgumentsFromProto(req.Msg.Arguments), req.Msg.Justification, user) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) + } + return connect.NewResponse(&apiv1.StartActionAndWaitResponse{ + LogEntry: api.internalLogEntryToPb(internalLogEntry, user), + }), nil } func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) { + binding := api.executor.FindBindingByID(req.Msg.ActionId) + if binding == nil || binding.Action == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId)) + } + args := make(map[string]string) execReq := executor.ExecutionRequest{ - Binding: api.executor.FindBindingByID(req.Msg.ActionId), + Binding: binding, TrackingID: uuid.NewString(), Arguments: args, AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg), @@ -253,12 +324,17 @@ func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[a } func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) { + binding := api.executor.FindBindingByID(req.Msg.ActionId) + if binding == nil || binding.Action == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId)) + } + args := make(map[string]string) user := auth.UserFromApiCall(ctx, req, api.cfg) execReq := executor.ExecutionRequest{ - Binding: api.executor.FindBindingByID(req.Msg.ActionId), + Binding: binding, TrackingID: uuid.NewString(), Arguments: args, AuthenticatedUser: user, @@ -274,9 +350,8 @@ func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Re return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{ LogEntry: api.internalLogEntryToPb(internalLogEntry, user), }), nil - } else { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found")) } func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string { @@ -302,6 +377,8 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry Output: logEntry.Output, TimedOut: logEntry.TimedOut, Blocked: logEntry.Blocked, + Queued: logEntry.Queued, + QueuedForGroup: logEntry.QueuedForGroup, ExitCode: logEntry.ExitCode, Tags: logEntry.Tags, ExecutionTrackingId: logEntry.ExecutionTrackingID, @@ -310,6 +387,7 @@ func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry User: logEntry.Username, BindingId: logEntry.GetBindingId(), DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry), + Justification: logEntry.Justification, } if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil { @@ -354,42 +432,78 @@ func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) * return ile } +func (api *oliveTinAPI) resolveExecutionStatusForView(msg *apiv1.ExecutionStatusRequest, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, error) { + ile := api.getExecutionStatusByRequest(msg) + if ile == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", msg.ExecutionTrackingId, msg.ActionId)) + } + if !isValidLogEntry(ile) || !api.isLogEntryAllowed(ile, user) { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution")) + } + return ile, nil +} + +func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRequest) *executor.InternalLogEntry { + if msg.ExecutionTrackingId != "" { + return getExecutionStatusByTrackingID(api, msg.ExecutionTrackingId) + } + return getMostRecentExecutionStatusByActionId(api, msg.ActionId) +} + +func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget { + if len(targets) == 0 { + return nil + } + + result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets)) + for _, target := range targets { + result = append(result, &apiv1.DashboardNavigationTarget{ + Title: target.Title, + EntityType: target.EntityType, + EntityKey: target.EntityKey, + Path: target.Path, + }) + } + + return result +} + +func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget { + if ile == nil || ile.Binding == nil { + return nil + } + + return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards) +} + func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) { - res := &apiv1.ExecutionStatusResponse{} - user := auth.UserFromApiCall(ctx, req, api.cfg) - if err := api.checkDashboardAccess(user); err != nil { return nil, err } - - var ile *executor.InternalLogEntry - - if req.Msg.ExecutionTrackingId != "" { - ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId) - - } else { - ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId) + ile, err := api.resolveExecutionStatusForView(req.Msg, user) + if err != nil { + return nil, err } - - if ile == nil { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", req.Msg.ExecutionTrackingId, req.Msg.ActionId)) - } else { - res.LogEntry = api.internalLogEntryToPb(ile, user) + res := &apiv1.ExecutionStatusResponse{ + LogEntry: api.internalLogEntryToPb(ile, user), + BackToDashboards: api.executionStatusBackToDashboards(ile), } - return connect.NewResponse(res), nil } func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) { user := auth.UserFromApiCall(ctx, req, api.cfg) + auth.RevokeSessionForProvider(api.cfg, user.Provider, user.SID) + log.WithFields(log.Fields{ "username": user.Username, "provider": user.Provider, }).Info("Logout: User logged out") response := connect.NewResponse(&apiv1.LogoutResponse{}) + secure := api.cookieSecure(req.Header()) // Clear the local authentication cookie by setting it to expire localCookie := &http.Cookie{ @@ -398,6 +512,8 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou MaxAge: -1, // This tells the browser to delete the cookie HttpOnly: true, Path: "/", + Secure: secure, + SameSite: http.SameSiteLaxMode, } response.Header().Set("Set-Cookie", localCookie.String()) @@ -408,6 +524,8 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou MaxAge: -1, // This tells the browser to delete the cookie HttpOnly: true, Path: "/", + Secure: secure, + SameSite: http.SameSiteLaxMode, } response.Header().Add("Set-Cookie", oauth2Cookie.String()) @@ -421,19 +539,35 @@ func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[a return nil, err } - binding := api.executor.FindBindingByID(req.Msg.BindingId) + resp, err := api.getActionBindingResponse(user, req.Msg.BindingId) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil +} - if binding == nil { - return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId)) +func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedUser, bindingId string) (*apiv1.GetActionBindingResponse, error) { + binding := api.executor.FindBindingByID(bindingId) + + if binding == nil || binding.Action == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId)) } - return connect.NewResponse(&apiv1.GetActionBindingResponse{ - Action: buildAction(binding, &DashboardRenderRequest{ - cfg: api.cfg, - AuthenticatedUser: user, - ex: api.executor, - }), - }), nil + if !api.userCanViewAction(user, binding.Action) { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied")) + } + + return &apiv1.GetActionBindingResponse{ + Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")), + BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards), + }, nil +} + +func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, action *config.Action) bool { + if user == nil { + return true + } + return acl.IsAllowedView(api.cfg, user, action) } func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) { @@ -466,13 +600,15 @@ func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) } func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest { - return &DashboardRenderRequest{ + rr := &DashboardRenderRequest{ AuthenticatedUser: user, cfg: api.cfg, ex: api.executor, EntityType: entityType, EntityKey: entityKey, } + populateActiveBindingStates(rr) + return rr } func (api *oliveTinAPI) isDefaultDashboard(title string) bool { @@ -494,6 +630,19 @@ func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, return connect.NewResponse(res), nil } +func resolveLogsPageSize(requestPageSize, defaultPageSize int64) int64 { + if requestPageSize == 0 { + return defaultPageSize + } + if requestPageSize < 10 { + return 10 + } + if requestPageSize > 100 { + return 100 + } + return requestPageSize +} + func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) { user := auth.UserFromApiCall(ctx, req, api.cfg) @@ -501,12 +650,12 @@ func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetL return nil, err } - ret := &apiv1.GetLogsResponse{} - dateFilter := "" - if req.Msg.DateFilter != "" { - dateFilter = req.Msg.DateFilter + pageSize := resolveLogsPageSize(req.Msg.GetPageSize(), api.cfg.LogHistoryPageSize) + logEntries, paging, err := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, pageSize, req.Msg.DateFilter, req.Msg.GetFilter()) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) } - logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize, dateFilter) + ret := &apiv1.GetLogsResponse{} for _, le := range logEntries { ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) } @@ -524,9 +673,20 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool { // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user. func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { + if user == nil || !isValidLogEntry(e) { + return false + } return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action) } +// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL). +func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool { + if user == nil { + return false + } + return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user) +} + // buildEmptyPageResponse creates a response for an empty page. func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse { return &apiv1.GetActionLogsResponse{ @@ -550,12 +710,13 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) { return startIdx, endIdx } -// buildActionLogsResponse builds the response with paginated log entries. +// buildActionLogsResponse builds the response with paginated log entries (newest first). func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse { startIdx, endIdx := calculateReversedIndices(page, len(filtered)) ret := &apiv1.GetActionLogsResponse{} - for _, le := range filtered[startIdx:endIdx] { - ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user)) + chunk := filtered[int(startIdx):int(endIdx)] + for i := len(chunk) - 1; i >= 0; i-- { + ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user)) } ret.CountRemaining = page.start ret.PageSize = page.size @@ -623,8 +784,56 @@ error messages more quickly before starting the action. It uses the same validation logic as the executor, including mangling argument values (e.g., datetime formatting, checkbox title-to-value conversion). */ +func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool { + if msg.BindingId == "" || msg.ArgumentName == "" { + return false + } + + arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName) + + return arg == nil +} + +func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error { + if msg == nil || msg.BindingId == "" { + return nil + } + + return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId) +} + +func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error { + binding := api.executor.FindBindingByID(bindingID) + if binding == nil || binding.Action == nil { + return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID)) + } + + if !api.userCanViewAction(user, binding.Action) { + return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied")) + } + + return nil +} + func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) { - err := api.validateArgumentTypeInternal(req.Msg) + user := auth.UserFromApiCall(ctx, req, api.cfg) + if err := api.checkDashboardAccess(user); err != nil { + return nil, err + } + + if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil { + return nil, err + } + + if api.argumentNotFoundForValidation(req.Msg) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId)) + } + + return api.validateArgumentTypeConnectResponse(req.Msg) +} + +func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) { + err := api.validateArgumentTypeInternal(msg) desc := "" if err != nil { desc = err.Error() @@ -687,7 +896,9 @@ func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAm } func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *connect.Request[apiv1.SosReportRequest]) (*connect.Response[apiv1.SosReportResponse], error) { - sos := installationinfo.GetSosReport() + user := auth.UserFromApiCall(ctx, req, api.cfg) + redactVersion := !user.EffectivePolicy.ShowVersionNumber + sos := installationinfo.GetSosReport(redactVersion) if !api.cfg.InsecureAllowDumpSos { log.Info(sos) @@ -722,6 +933,13 @@ func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.Dum return connect.NewResponse(res), nil } +func debugBindingActionTitle(binding *executor.ActionBinding) string { + if binding == nil || binding.Action == nil { + return "" + } + return binding.Action.Title +} + func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) { res := &apiv1.DumpPublicIdActionMapResponse{} res.Contents = make(map[string]*apiv1.DebugBinding) @@ -736,7 +954,7 @@ func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Requ for k, v := range api.executor.MapActionBindings { res.Contents[k] = &apiv1.DebugBinding{ - ActionTitle: v.Action.Title, + ActionTitle: debugBindingActionTitle(v), } } @@ -771,6 +989,8 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1. client := &streamingClient{ channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events AuthenticatedUser: user, + heartbeatStop: make(chan struct{}), + heartbeatDone: make(chan struct{}), } log.WithFields(log.Fields{ @@ -781,6 +1001,8 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1. api.streamingClients[client] = struct{}{} api.streamingClientsMutex.Unlock() + go api.sendEventStreamHeartbeats(client) + // loop over client channel and send events to connectedClient for msg := range client.channel { log.Debugf("Sending event to client: %v", msg) @@ -797,10 +1019,61 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1. return nil } +func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) { + defer close(client.heartbeatDone) + + if !api.sendEventStreamHeartbeat(client) { + go api.removeClient(client) + return + } + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + api.runEventStreamHeartbeatLoop(client, ticker) +} + +func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) { + for { + if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) { + return + } + if !api.sendEventStreamHeartbeat(client) { + go api.removeClient(client) + return + } + } +} + +func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool { + select { + case <-done: + return true + case <-ticker.C: + return false + } +} + +func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool { + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_Heartbeat{ + Heartbeat: &apiv1.EventHeartbeat{}, + }, + } + return api.trySendEventToClient(client, msg) +} + func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) { + if clientToRemove == nil { + return + } api.streamingClientsMutex.Lock() + if _, exists := api.streamingClients[clientToRemove]; !exists { + api.streamingClientsMutex.Unlock() + return + } delete(api.streamingClients, clientToRemove) api.streamingClientsMutex.Unlock() + clientToRemove.stopHeartbeat() close(clientToRemove.channel) } @@ -808,14 +1081,12 @@ func (api *oliveTinAPI) OnActionMapRebuilt() { toRemove := []*streamingClient{} for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ + msg := &apiv1.EventStreamResponse{ Event: &apiv1.EventStreamResponse_ConfigChanged{ ConfigChanged: &apiv1.EventConfigChanged{}, }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") + } + if !api.trySendEventToClient(client, msg) { toRemove = append(toRemove, client) } } @@ -827,56 +1098,74 @@ func (api *oliveTinAPI) OnActionMapRebuilt() { func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) { toRemove := []*streamingClient{} - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_ExecutionStarted{ - ExecutionStarted: &apiv1.EventExecutionStarted{ - LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + api.maybeSendExecutionStarted(client, ex, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_ExecutionStarted{ + ExecutionStarted: &apiv1.EventExecutionStarted{ + LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser), + }, + }, + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) { toRemove := []*streamingClient{} - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_ExecutionFinished{ - ExecutionFinished: &apiv1.EventExecutionFinished{ - LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser), - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + api.maybeSendExecutionFinished(client, ile, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_ExecutionFinished{ + ExecutionFinished: &apiv1.EventExecutionFinished{ + LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser), + }, + }, + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) { + user := auth.UserFromApiCall(ctx, req, api.cfg) + if err := api.checkDashboardAccess(user); err != nil { + return nil, err + } + if !user.EffectivePolicy.ShowDiagnostics { + return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account")) + } res := &apiv1.GetDiagnosticsResponse{ SshFoundKey: installationinfo.Runtime.SshFoundKey, SshFoundConfig: installationinfo.Runtime.SshFoundConfig, } - return connect.NewResponse(res), nil } @@ -885,12 +1174,19 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin + showVersion := user.EffectivePolicy.ShowVersionNumber + currentVersion := "" + availableVersion := "" + if showVersion { + currentVersion = installationinfo.Build.Version + availableVersion = installationinfo.Runtime.AvailableVersion + } res := &apiv1.InitResponse{ ShowFooter: api.cfg.ShowFooter, ShowNavigation: api.cfg.ShowNavigation, - ShowNewVersions: api.cfg.ShowNewVersions, - AvailableVersion: installationinfo.Runtime.AvailableVersion, - CurrentVersion: installationinfo.Build.Version, + ShowNewVersions: showVersion && api.cfg.ShowNewVersions, + AvailableVersion: availableVersion, + CurrentVersion: currentVersion, PageTitle: api.cfg.PageTitle, SectionNavigationStyle: api.cfg.SectionNavigationStyle, DefaultIconForBack: api.cfg.DefaultIconForBack, @@ -1027,29 +1323,47 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin } func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) { - toRemove := []*streamingClient{} - - for _, client := range api.copyOfStreamingClients() { - select { - case client.channel <- &apiv1.EventStreamResponse{ - Event: &apiv1.EventStreamResponse_OutputChunk{ - OutputChunk: &apiv1.EventOutputChunk{ - Output: string(content), - ExecutionTrackingId: executionTrackingId, - }, - }, - }: - default: - log.Warnf("EventStream: client channel is full, removing client") - toRemove = append(toRemove, client) - } + entry := api.getValidLogEntryForStreaming(executionTrackingId) + if entry == nil { + return + } + msg := &apiv1.EventStreamResponse{ + Event: &apiv1.EventStreamResponse_OutputChunk{ + OutputChunk: &apiv1.EventOutputChunk{ + Output: string(content), + ExecutionTrackingId: executionTrackingId, + }, + }, + } + toRemove := []*streamingClient{} + for _, client := range api.copyOfStreamingClients() { + api.maybeSendOutputChunk(client, entry, msg, &toRemove) } - for _, client := range toRemove { api.removeClient(client) } } +func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry { + entry, ok := api.executor.GetLog(executionTrackingId) + if !ok || !isValidLogEntry(entry) { + return nil + } + return entry +} + +func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) { + if client == nil { + return + } + if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) { + return + } + if !api.trySendEventToClient(client, msg) { + *toRemove = append(*toRemove, client) + } +} + func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) { user := auth.UserFromApiCall(ctx, req, api.cfg) @@ -1229,48 +1543,84 @@ func serializeEntityFields(data any) map[string]string { } func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) { - ret := &apiv1.StartActionResponse{ - ExecutionTrackingId: req.Msg.ExecutionTrackingId, + execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId) + if err != nil { + return nil, err } - var execReqLogEntry *executor.InternalLogEntry + if execReqLogEntry.Binding.Action.Justification { + return nil, restartRequiresJustificationError() + } - execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId) + authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg) + execReq := executor.ExecutionRequest{ + Binding: execReqLogEntry.Binding, + Arguments: make(map[string]string), + AuthenticatedUser: authenticatedUser, + Cfg: api.cfg, + } + api.executor.ExecRequest(&execReq) + + return connect.NewResponse(&apiv1.StartActionResponse{ + ExecutionTrackingId: execReq.TrackingID, + }), nil +} + +func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) { + execReqLogEntry, found := api.executor.GetLog(executionTrackingId) if !found { - log.Warnf("Restarting execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId) - return connect.NewResponse(ret), nil + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId)) } - log.Warnf("Restarting execution request by tracking ID: %v", req.Msg.ExecutionTrackingId) - - action := execReqLogEntry.Binding.Action - - if action == nil { - log.Warnf("Restarting execution request not possible - action not found: %v", execReqLogEntry.ActionTitle) - return connect.NewResponse(ret), nil + if execReqLogEntry.Binding == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId)) } - return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{ - Msg: &apiv1.StartActionRequest{ - // FIXME - UniqueTrackingId: req.Msg.ExecutionTrackingId, - }, - }) + if execReqLogEntry.Binding.Action == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId)) + } + + return execReqLogEntry, nil +} + +var ( + executorListenersMu sync.Mutex + executorListeners = map[*executor.Executor]*oliveTinAPI{} +) + +// RegisterExecutorListener registers the API server as an executor listener during startup. +// Call this before background goroutines that may trigger RebuildActionMap. +func RegisterExecutorListener(ex *executor.Executor) { + ensureExecutorListener(ex) +} + +func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI { + executorListenersMu.Lock() + defer executorListenersMu.Unlock() + + if server, ok := executorListeners[ex]; ok { + return server + } + + server := newServer(ex) + executorListeners[ex] = server + return server } func newServer(ex *executor.Executor) *oliveTinAPI { - server := oliveTinAPI{} - server.cfg = ex.Cfg - server.executor = ex - server.streamingClients = make(map[*streamingClient]struct{}) + server := &oliveTinAPI{ + cfg: ex.Cfg, + executor: ex, + streamingClients: make(map[*streamingClient]struct{}), + } - ex.AddListener(&server) - return &server + ex.AddListener(server) + return server } func GetNewHandler(ex *executor.Executor) (string, http.Handler) { - server := newServer(ex) + server := ensureExecutorListener(ex) jsonOpt := connectproto.WithJSON( protojson.MarshalOptions{ diff --git a/service/internal/api/apiActionExecTriggers.go b/service/internal/api/apiActionExecTriggers.go new file mode 100644 index 0000000..96ce651 --- /dev/null +++ b/service/internal/api/apiActionExecTriggers.go @@ -0,0 +1,27 @@ +package api + +import ( + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func applyActionExecTriggers(pb *apiv1.Action, cfg *config.Action) { + if cfg == nil { + return + } + + pb.ExecOnStartup = cfg.ExecOnStartup + pb.ExecOnCron = append([]string(nil), cfg.ExecOnCron...) + pb.ExecOnFileCreatedInDir = append([]string(nil), cfg.ExecOnFileCreatedInDir...) + pb.ExecOnFileChangedInDir = append([]string(nil), cfg.ExecOnFileChangedInDir...) + pb.ExecOnCalendarFile = cfg.ExecOnCalendarFile + + for _, wh := range cfg.ExecOnWebhook { + pb.ExecOnWebhooks = append(pb.ExecOnWebhooks, &apiv1.ActionWebhookExecHint{ + Template: wh.Template, + MatchPath: wh.MatchPath, + MatchHeaders: wh.MatchHeaders, + MatchQuery: wh.MatchQuery, + }) + } +} diff --git a/service/internal/api/apiActions.go b/service/internal/api/apiActions.go index 23f8f40..aaac144 100644 --- a/service/internal/api/apiActions.go +++ b/service/internal/api/apiActions.go @@ -16,30 +16,78 @@ import ( "github.com/OliveTin/OliveTin/internal/tpl" ) +type bindingActiveState struct { + hasRunning bool + hasQueued bool +} + type DashboardRenderRequest struct { - AuthenticatedUser *authpublic.AuthenticatedUser - cfg *config.Config - ex *executor.Executor - EntityType string - EntityKey string + AuthenticatedUser *authpublic.AuthenticatedUser + cfg *config.Config + ex *executor.Executor + EntityType string + EntityKey string + activeBindingStates map[string]bindingActiveState +} + +func activeBindingID(entry *executor.InternalLogEntry) string { + if entry == nil || entry.ExecutionFinished { + return "" + } + return entry.GetBindingId() +} + +func applyEntryToBindingState(state bindingActiveState, entry *executor.InternalLogEntry) bindingActiveState { + if entry.ExecutionStarted { + state.hasRunning = true + } else { + state.hasQueued = true + } + return state +} + +func buildActiveBindingStates(active []*executor.InternalLogEntry) map[string]bindingActiveState { + states := make(map[string]bindingActiveState) + + for _, entry := range active { + bindingID := activeBindingID(entry) + if bindingID == "" { + continue + } + states[bindingID] = applyEntryToBindingState(states[bindingID], entry) + } + + return states +} + +func populateActiveBindingStates(rr *DashboardRenderRequest) { + if rr == nil || rr.ex == nil || rr.activeBindingStates != nil { + return + } + + rr.activeBindingStates = buildActiveBindingStates(rr.ex.GetActiveExecutionsACL(rr.cfg, rr.AuthenticatedUser)) } func (rr *DashboardRenderRequest) findAction(title string) *apiv1.Action { return rr.findActionForEntity(title, nil) } +func bindingMatchesTitleAndEntity(binding *executor.ActionBinding, title string, entity *entities.Entity) bool { + return binding != nil && binding.Action != nil && binding.Action.Title == title && matchesEntity(binding, entity) +} + func (rr *DashboardRenderRequest) findActionForEntity(title string, entity *entities.Entity) *apiv1.Action { rr.ex.MapActionBindingsLock.RLock() defer rr.ex.MapActionBindingsLock.RUnlock() for _, binding := range rr.ex.MapActionBindings { - if binding.Action.Title != title { + if !bindingMatchesTitleAndEntity(binding, title, entity) { continue } - - if matchesEntity(binding, entity) { - return buildAction(binding, rr) + if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) { + return nil } + return buildAction(binding, rr) } return nil @@ -55,8 +103,9 @@ func matchesEntity(binding *executor.ActionBinding, entity *entities.Entity) boo func buildEffectivePolicy(policy *config.ConfigurationPolicy) *apiv1.EffectivePolicy { ret := &apiv1.EffectivePolicy{ - ShowDiagnostics: policy.ShowDiagnostics, - ShowLogList: policy.ShowLogList, + ShowDiagnostics: policy.ShowDiagnostics, + ShowLogList: policy.ShowLogList, + ShowVersionNumber: policy.ShowVersionNumber, } return ret @@ -116,48 +165,103 @@ func getDefaultArgumentValue(cfgArg config.ActionArgument, entity *entities.Enti return defaultValue } -func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action { - action := actionBinding.Action - - aclCanExec := acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) - enabledExprCanExec := evaluateEnabledExpression(action, actionBinding.Entity) - - // Calculate rate limit expiry time - expiryUnix := rr.ex.GetTimeUntilAvailable(actionBinding) - datetimeRateLimitExpires := "" - if expiryUnix > 0 { - datetimeRateLimitExpires = time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05") +func formatRateLimitExpiry(expiryUnix int64) string { + if expiryUnix <= 0 { + return "" } + return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05") +} - btn := apiv1.Action{ - BindingId: actionBinding.ID, - Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, actionBinding.Entity), - Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, actionBinding.Entity), - CanExec: aclCanExec && enabledExprCanExec, - PopupOnStart: action.PopupOnStart, - Order: int32(actionBinding.ConfigOrder), - Timeout: int32(action.Timeout), - DatetimeRateLimitExpires: datetimeRateLimitExpires, +func actionFromBinding(actionBinding *executor.ActionBinding) (*executor.ActionBinding, *config.Action) { + if actionBinding == nil || actionBinding.Action == nil { + return nil, nil } + return actionBinding, actionBinding.Action +} +func applyActiveBindingStateToAction(btn *apiv1.Action, bindingID string, states map[string]bindingActiveState) { + if states == nil { + return + } + state, ok := states[bindingID] + if !ok { + return + } + btn.HasRunningInstance = state.hasRunning + btn.HasQueuedInstance = state.hasQueued +} + +func buildActionArguments(action *config.Action, entity *entities.Entity) []*apiv1.ActionArgument { + args := make([]*apiv1.ActionArgument, 0, len(action.Arguments)) for _, cfgArg := range action.Arguments { - pbArg := apiv1.ActionArgument{ + args = append(args, &apiv1.ActionArgument{ Name: cfgArg.Name, Title: cfgArg.Title, Type: cfgArg.Type, Description: cfgArg.Description, - DefaultValue: getDefaultArgumentValue(cfgArg, actionBinding.Entity), + DefaultValue: getDefaultArgumentValue(cfgArg, entity), Choices: buildChoices(cfgArg), Suggestions: cfgArg.Suggestions, SuggestionsBrowserKey: cfgArg.SuggestionsBrowserKey, - } + }) + } + return args +} - btn.Arguments = append(btn.Arguments, &pbArg) +func buildAction(actionBinding *executor.ActionBinding, rr *DashboardRenderRequest) *apiv1.Action { + binding, action := actionFromBinding(actionBinding) + if binding == nil { + return nil } + btn := apiv1.Action{ + BindingId: binding.ID, + Title: tpl.ParseTemplateOfActionBeforeExec(action.Title, binding.Entity), + Icon: tpl.ParseTemplateOfActionBeforeExec(action.Icon, binding.Entity), + CanExec: acl.IsAllowedExec(rr.cfg, rr.AuthenticatedUser, action) && evaluateEnabledExpression(action, binding.Entity), + PopupOnStart: action.OnClick, + Order: int32(binding.ConfigOrder), + Timeout: int32(action.Timeout), + DatetimeRateLimitExpires: formatRateLimitExpiry(rr.ex.GetTimeUntilAvailable(binding)), + Justification: action.Justification, + } + + applyActiveBindingStateToAction(&btn, binding.ID, rr.activeBindingStates) + applyActionExecTriggers(&btn, action) + btn.Arguments = buildActionArguments(action, binding.Entity) + btn.Groups = buildActionGroups(action, rr.cfg) + return &btn } +func buildActionGroups(action *config.Action, cfg *config.Config) []*apiv1.ActionGroupMembership { + if action == nil || len(action.Groups) == 0 { + return nil + } + + groups := make([]*apiv1.ActionGroupMembership, 0, len(action.Groups)) + + for _, name := range action.Groups { + groups = append(groups, actionGroupMembershipFromConfig(name, cfg)) + } + + return groups +} + +func actionGroupMembershipFromConfig(name string, cfg *config.Config) *apiv1.ActionGroupMembership { + membership := &apiv1.ActionGroupMembership{Name: name} + + group, found := cfg.ActionGroups[name] + if !found || group == nil || group.MaxConcurrent < 1 { + return membership + } + + membership.MaxConcurrent = int32(group.MaxConcurrent) + membership.QueueSize = int32(group.QueueSize) + + return membership +} + func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice { if arg.Entity != "" && len(arg.Choices) == 1 { return buildChoicesEntity(arg.Choices[0], arg.Entity) @@ -169,9 +273,7 @@ func buildChoices(arg config.ActionArgument) []*apiv1.ActionArgumentChoice { func buildChoicesEntity(firstChoice config.ActionArgumentChoice, entityTitle string) []*apiv1.ActionArgumentChoice { ret := []*apiv1.ActionArgumentChoice{} - entList := entities.GetEntityInstances(entityTitle) - - for _, ent := range entList { + for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) { ret = append(ret, &apiv1.ActionArgumentChoice{ Value: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Value, ent), Title: tpl.ParseTemplateOfActionBeforeExec(firstChoice.Title, ent), diff --git a/service/internal/api/api_actions_active_test.go b/service/internal/api/api_actions_active_test.go new file mode 100644 index 0000000..6e99146 --- /dev/null +++ b/service/internal/api/api_actions_active_test.go @@ -0,0 +1,92 @@ +package api + +import ( + "testing" + + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildActiveBindingStates(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{ + {Title: "backup", Shell: "sleep 1", MaxConcurrent: 1}, + {Title: "ping", Shell: "echo ping"}, + } + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + backupBinding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + pingBinding := ex.FindBindingWithNoEntity(cfg.Actions[1]) + require.NotNil(t, backupBinding) + require.NotNil(t, pingBinding) + + backupRunning := newAPIQueueLogEntry(backupBinding, true, false) + backupWaiting := newAPIQueueLogEntry(backupBinding, false, false) + pingRunning := newAPIQueueLogEntry(pingBinding, true, false) + + states := buildActiveBindingStates([]*executor.InternalLogEntry{ + backupRunning, + backupWaiting, + pingRunning, + }) + + backupState, ok := states[backupBinding.ID] + require.True(t, ok) + assert.True(t, backupState.hasRunning) + assert.True(t, backupState.hasQueued) + + pingState, ok := states[pingBinding.ID] + require.True(t, ok) + assert.True(t, pingState.hasRunning) + assert.False(t, pingState.hasQueued) +} + +func TestBuildActionIncludesActiveBindingState(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{ + {Title: "backup", Shell: "sleep 1"}, + } + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + binding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, binding) + + running := newAPIQueueLogEntry(binding, true, false) + queued := newAPIQueueLogEntry(binding, false, false) + ex.SetLog(running.ExecutionTrackingID, running) + ex.SetLog(queued.ExecutionTrackingID, queued) + + rr := &DashboardRenderRequest{ + cfg: cfg, + ex: ex, + } + populateActiveBindingStates(rr) + + action := buildAction(binding, rr) + require.NotNil(t, action) + assert.True(t, action.HasRunningInstance) + assert.True(t, action.HasQueuedInstance) +} + +func TestBuildActiveBindingStatesIgnoresFinished(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{{Title: "backup", Shell: "sleep 1"}} + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, binding) + + finished := newAPIQueueLogEntry(binding, true, true) + states := buildActiveBindingStates([]*executor.InternalLogEntry{finished}) + assert.Empty(t, states) +} diff --git a/service/internal/api/api_justification.go b/service/internal/api/api_justification.go new file mode 100644 index 0000000..6917167 --- /dev/null +++ b/service/internal/api/api_justification.go @@ -0,0 +1,45 @@ +package api + +import ( + "fmt" + "strings" + + "connectrpc.com/connect" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" +) + +func validateJustificationRequired(action *config.Action, justification string, user *authpublic.AuthenticatedUser) error { + if !actionRequiresJustificationConfig(action) || justificationProvided(justification, user) { + return nil + } + + return fmt.Errorf("justification is required for this action") +} + +func actionRequiresJustificationConfig(action *config.Action) bool { + return action != nil && action.Justification +} + +func justificationProvided(justification string, user *authpublic.AuthenticatedUser) bool { + return strings.TrimSpace(justification) != "" || executor.IsSystemExecution(user) +} + +func connectInvalidJustification(err error) error { + return connect.NewError(connect.CodeInvalidArgument, err) +} + +func startActionArgumentsFromProto(args []*apiv1.StartActionArgument) map[string]string { + result := make(map[string]string, len(args)) + for _, arg := range args { + result[arg.Name] = arg.Value + } + return result +} + +func restartRequiresJustificationError() error { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("justification is required for this action; use StartAction with a justification instead")) +} diff --git a/service/internal/api/api_justification_test.go b/service/internal/api/api_justification_test.go new file mode 100644 index 0000000..7465e72 --- /dev/null +++ b/service/internal/api/api_justification_test.go @@ -0,0 +1,89 @@ +package api + +import ( + "context" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + "github.com/OliveTin/OliveTin/internal/auth" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" +) + +func TestStartActionRequiresJustificationForGuest(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{ + Title: "Send email", + ID: "send_email", + Justification: true, + Shell: "echo done", + } + cfg.Actions = append(cfg.Actions, action) + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(action) + require.NotNil(t, binding) + + ts, client := getNewTestServerAndClientWithExecutor(cfg, ex) + defer ts.Close() + + _, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{ + BindingId: binding.ID, + UniqueTrackingId: uuid.NewString(), + })) + require.Error(t, err) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) + + resp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{ + BindingId: binding.ID, + UniqueTrackingId: uuid.NewString(), + Justification: "New user registration foo@example.com", + })) + require.NoError(t, err) + require.NotEmpty(t, resp.Msg.ExecutionTrackingId) + + time.Sleep(200 * time.Millisecond) + + entry, ok := ex.GetLog(resp.Msg.ExecutionTrackingId) + require.True(t, ok) + assert.Equal(t, "New user registration foo@example.com", entry.Justification) +} + +func TestBuildActionExposesJustificationFlag(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{ + Title: "Audited action", + ID: "audited", + Justification: true, + Shell: "echo hi", + } + cfg.Actions = append(cfg.Actions, action) + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(action) + require.NotNil(t, binding) + + pb := buildAction(binding, &DashboardRenderRequest{ + cfg: cfg, + ex: ex, + }) + + require.NotNil(t, pb) + assert.True(t, pb.Justification) +} + +func TestValidateJustificationRequiredAllowsSystemUser(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Cron job", Justification: true} + + err := validateJustificationRequired(action, "", auth.UserFromSystem(cfg, "cron")) + require.NoError(t, err) +} diff --git a/service/internal/api/api_logs_filter_test.go b/service/internal/api/api_logs_filter_test.go new file mode 100644 index 0000000..0c7e2ec --- /dev/null +++ b/service/internal/api/api_logs_filter_test.go @@ -0,0 +1,76 @@ +package api + +import ( + "context" + "testing" + "time" + + "connectrpc.com/connect" + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetLogsFilterExpression(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{ + {Title: "Update packages", Shell: "echo update"}, + {Title: "Ping host", Shell: "echo ping"}, + } + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + updateBinding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + pingBinding := ex.FindBindingWithNoEntity(cfg.Actions[1]) + require.NotNil(t, updateBinding) + require.NotNil(t, pingBinding) + + ex.SetLog(uuid.NewString(), finishedLogEntry(updateBinding, "Update packages", "Completed")) + ex.SetLog(uuid.NewString(), finishedLogEntry(pingBinding, "Ping host", "Blocked")) + + ts, client := getNewTestServerAndClientWithExecutor(cfg, ex) + defer ts.Close() + + resp, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{ + Filter: "!Update", + })) + require.NoError(t, err) + require.Len(t, resp.Msg.Logs, 1) + assert.Equal(t, "Ping host", resp.Msg.Logs[0].ActionTitle) +} + +func TestGetLogsInvalidFilterReturnsError(t *testing.T) { + cfg := config.DefaultConfig() + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + _, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{ + Filter: `SecretField == "x"`, + })) + require.Error(t, err) + assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err)) +} + +func finishedLogEntry(binding *executor.ActionBinding, title, status string) *executor.InternalLogEntry { + entry := &executor.InternalLogEntry{ + Binding: binding, + DatetimeStarted: time.Now(), + DatetimeFinished: time.Now(), + ExecutionTrackingID: uuid.NewString(), + ActionTitle: title, + ExecutionFinished: true, + Username: "guest", + } + switch status { + case "Blocked": + entry.Blocked = true + case "Completed": + entry.ExitCode = 0 + } + return entry +} diff --git a/service/internal/api/api_queue.go b/service/internal/api/api_queue.go new file mode 100644 index 0000000..b25fa60 --- /dev/null +++ b/service/internal/api/api_queue.go @@ -0,0 +1,227 @@ +package api + +import ( + ctx "context" + "sort" + + "connectrpc.com/connect" + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + "github.com/OliveTin/OliveTin/internal/auth" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" +) + +const defaultActionGroupName = "default" + +type executionQueueBucketKey struct { + groupName string + bindingID string +} + +func (api *oliveTinAPI) GetExecutionQueue(ctx ctx.Context, req *connect.Request[apiv1.GetExecutionQueueRequest]) (*connect.Response[apiv1.GetExecutionQueueResponse], error) { + user := auth.UserFromApiCall(ctx, req, api.cfg) + + if err := api.checkDashboardAccess(user); err != nil { + return nil, err + } + + active := api.executor.GetActiveExecutionsACL(api.cfg, user) + groups := buildExecutionQueueGroups(active, user, api) + + return connect.NewResponse(&apiv1.GetExecutionQueueResponse{ + Groups: groups, + TotalActive: int32(len(active)), + }), nil +} + +func buildExecutionQueueGroups(active []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser, api *oliveTinAPI) []*apiv1.ExecutionQueueGroup { + actionBuckets := make(map[executionQueueBucketKey]*apiv1.ExecutionQueueAction) + + for _, entry := range active { + addActiveEntryToActionBuckets(actionBuckets, entry, api.cfg, user, api) + } + + return buildExecutionQueueGroupsFromBuckets(actionBuckets, api.cfg) +} + +func addActiveEntryToActionBuckets( + buckets map[executionQueueBucketKey]*apiv1.ExecutionQueueAction, + entry *executor.InternalLogEntry, + cfg *config.Config, + user *authpublic.AuthenticatedUser, + api *oliveTinAPI, +) { + for _, groupName := range enforcedActionGroupNames(entry, cfg) { + key := executionQueueBucketKey{ + groupName: groupName, + bindingID: entry.GetBindingId(), + } + + action := buckets[key] + if action == nil { + action = newExecutionQueueAction(entry) + buckets[key] = action + } + + action.Entries = append(action.Entries, api.internalLogEntryToPb(entry, user)) + } +} + +func finalizeExecutionQueueGroup(group *apiv1.ExecutionQueueGroup) { + sortExecutionQueueActions(group.Actions) + group.ActiveCount = sumExecutionQueueActionEntries(group.Actions) + group.QueuedCount = countQueuedGroupEntries(group.Actions) +} + +func buildExecutionQueueGroupsFromBuckets( + buckets map[executionQueueBucketKey]*apiv1.ExecutionQueueAction, + cfg *config.Config, +) []*apiv1.ExecutionQueueGroup { + grouped := make(map[string]*apiv1.ExecutionQueueGroup) + + for key, action := range buckets { + sortQueueEntries(action.Entries) + action.ActiveCount = int32(len(action.Entries)) + + group := grouped[key.groupName] + if group == nil { + group = newExecutionQueueGroup(key.groupName, cfg) + grouped[key.groupName] = group + } + + group.Actions = append(group.Actions, action) + } + + groups := make([]*apiv1.ExecutionQueueGroup, 0, len(grouped)) + for _, group := range grouped { + finalizeExecutionQueueGroup(group) + groups = append(groups, group) + } + + sortExecutionQueueGroups(groups) + return groups +} + +func hasExecutionQueueBinding(entry *executor.InternalLogEntry, cfg *config.Config) bool { + return entry != nil && entry.Binding != nil && entry.Binding.Action != nil && cfg != nil +} + +func collectEnforcedActionGroupNames(groups []string, cfg *config.Config) []string { + names := make([]string, 0, len(groups)) + for _, groupName := range groups { + if isEnforcedActionGroup(cfg, groupName) { + names = append(names, groupName) + } + } + return names +} + +func enforcedActionGroupNames(entry *executor.InternalLogEntry, cfg *config.Config) []string { + if !hasExecutionQueueBinding(entry, cfg) { + return []string{defaultActionGroupName} + } + + names := collectEnforcedActionGroupNames(entry.Binding.Action.Groups, cfg) + if len(names) == 0 { + return []string{defaultActionGroupName} + } + + return names +} + +func isEnforcedActionGroup(cfg *config.Config, groupName string) bool { + group, found := cfg.ActionGroups[groupName] + return found && group != nil && group.MaxConcurrent >= 1 +} + +func newExecutionQueueGroup(name string, cfg *config.Config) *apiv1.ExecutionQueueGroup { + group := &apiv1.ExecutionQueueGroup{Name: name} + if name == defaultActionGroupName { + return group + } + + actionGroup, found := cfg.ActionGroups[name] + if !found || actionGroup == nil { + return group + } + + group.Icon = actionGroup.Icon + group.MaxConcurrent = int32(actionGroup.MaxConcurrent) + group.QueueSize = int32(actionGroup.QueueSize) + return group +} + +func newExecutionQueueAction(entry *executor.InternalLogEntry) *apiv1.ExecutionQueueAction { + action := &apiv1.ExecutionQueueAction{ + BindingId: entry.GetBindingId(), + ActionTitle: entry.ActionTitle, + ActionIcon: entry.ActionIcon, + EntityPrefix: entry.EntityPrefix, + } + + if entry.Binding != nil && entry.Binding.Action != nil { + action.MaxConcurrent = int32(entry.Binding.Action.MaxConcurrent) + } + + return action +} + +func sumExecutionQueueActionEntries(actions []*apiv1.ExecutionQueueAction) int32 { + var total int32 + + for _, action := range actions { + total += int32(len(action.Entries)) + } + + return total +} + +func countQueuedGroupEntries(actions []*apiv1.ExecutionQueueAction) int32 { + var total int32 + + for _, action := range actions { + for _, entry := range action.Entries { + if entry.Queued { + total++ + } + } + } + + return total +} + +func sortQueueEntries(entries []*apiv1.LogEntry) { + sort.Slice(entries, func(i, j int) bool { + return entries[i].DatetimeStarted < entries[j].DatetimeStarted + }) +} + +func sortExecutionQueueActions(actions []*apiv1.ExecutionQueueAction) { + sort.Slice(actions, func(i, j int) bool { + left := actions[i].ActionTitle + right := actions[j].ActionTitle + if left == right { + return actions[i].EntityPrefix < actions[j].EntityPrefix + } + + return left < right + }) +} + +func sortExecutionQueueGroups(groups []*apiv1.ExecutionQueueGroup) { + sort.Slice(groups, func(i, j int) bool { + left := groups[i].Name + right := groups[j].Name + + if left == defaultActionGroupName { + return false + } + + if right == defaultActionGroupName { + return true + } + + return left < right + }) +} diff --git a/service/internal/api/api_queue_test.go b/service/internal/api/api_queue_test.go new file mode 100644 index 0000000..3d7d77d --- /dev/null +++ b/service/internal/api/api_queue_test.go @@ -0,0 +1,103 @@ +package api + +import ( + "context" + "testing" + "time" + + "connectrpc.com/connect" + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/executor" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetExecutionQueueGroupsByActionGroup(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ActionGroups = map[string]*config.ActionGroup{ + "deploy": {MaxConcurrent: 2, Icon: "backup"}, + } + cfg.Actions = []*config.Action{ + {Title: "backup", Shell: "sleep 1", MaxConcurrent: 1, Groups: []string{"deploy"}}, + {Title: "ping", Shell: "echo ping"}, + } + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + backupBinding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + pingBinding := ex.FindBindingWithNoEntity(cfg.Actions[1]) + require.NotNil(t, backupBinding) + require.NotNil(t, pingBinding) + + backupRunning := newAPIQueueLogEntry(backupBinding, true, false) + backupWaiting := newAPIQueueLogEntry(backupBinding, false, false) + backupWaiting.Queued = true + pingRunning := newAPIQueueLogEntry(pingBinding, true, false) + + ex.SetLog(backupRunning.ExecutionTrackingID, backupRunning) + ex.SetLog(backupWaiting.ExecutionTrackingID, backupWaiting) + ex.SetLog(pingRunning.ExecutionTrackingID, pingRunning) + + ts, client := getNewTestServerAndClientWithExecutor(cfg, ex) + defer ts.Close() + + resp, err := client.GetExecutionQueue(context.Background(), connect.NewRequest(&apiv1.GetExecutionQueueRequest{})) + require.NoError(t, err) + require.NotNil(t, resp) + assert.Equal(t, int32(3), resp.Msg.TotalActive) + require.Len(t, resp.Msg.Groups, 2) + + deployGroup := findExecutionQueueGroup(resp.Msg.Groups, "deploy") + defaultGroup := findExecutionQueueGroup(resp.Msg.Groups, defaultActionGroupName) + require.NotNil(t, deployGroup) + require.NotNil(t, defaultGroup) + + assert.Equal(t, int32(2), deployGroup.MaxConcurrent) + assert.Equal(t, int32(5), deployGroup.QueueSize) + assert.Equal(t, "💾", deployGroup.Icon) + assert.Equal(t, int32(2), deployGroup.ActiveCount) + assert.Equal(t, int32(1), deployGroup.QueuedCount) + require.Len(t, deployGroup.Actions, 1) + assert.Equal(t, "backup", deployGroup.Actions[0].ActionTitle) + require.Len(t, deployGroup.Actions[0].Entries, 2) + + require.Len(t, defaultGroup.Actions, 1) + assert.Equal(t, "ping", defaultGroup.Actions[0].ActionTitle) + assert.Equal(t, int32(1), defaultGroup.Actions[0].ActiveCount) +} + +func findExecutionQueueGroup(groups []*apiv1.ExecutionQueueGroup, name string) *apiv1.ExecutionQueueGroup { + for _, group := range groups { + if group.Name == name { + return group + } + } + + return nil +} + +func newAPIQueueLogEntry(binding *executor.ActionBinding, started bool, finished bool) *executor.InternalLogEntry { + startedAt := time.Now().Add(-time.Minute) + if started { + startedAt = time.Now().Add(-2 * time.Minute) + } + + entry := &executor.InternalLogEntry{ + Binding: binding, + DatetimeStarted: startedAt, + ExecutionTrackingID: uuid.NewString(), + ActionTitle: binding.Action.Title, + ExecutionStarted: started, + ExecutionFinished: finished, + } + + if finished { + entry.DatetimeFinished = time.Now() + } + + return entry +} diff --git a/service/internal/api/api_test.go b/service/internal/api/api_test.go index 790e315..90f8ed1 100644 --- a/service/internal/api/api_test.go +++ b/service/internal/api/api_test.go @@ -2,12 +2,17 @@ package api import ( "context" + "net/http" + "net/http/httptest" + "path" "testing" + "time" "connectrpc.com/connect" - "github.com/stretchr/testify/assert" - + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect" @@ -15,16 +20,15 @@ import ( config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/executor" - - "net/http" - "net/http/httptest" - "path" ) func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) { ex := executor.DefaultExecutor(injectedConfig) ex.RebuildActionMap() + return getNewTestServerAndClientWithExecutor(injectedConfig, ex) +} +func getNewTestServerAndClientWithExecutor(injectedConfig *config.Config, ex *executor.Executor) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) { apiPath, apiHandler := GetNewHandler(ex) mux := http.NewServeMux() @@ -51,6 +55,24 @@ func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, return ts, client } +func TestApplyActionExecTriggersIncludesWebhookHeaderAndQueryMatches(t *testing.T) { + cfg := &config.Action{ + ExecOnWebhook: []config.WebhookConfig{ + { + MatchHeaders: map[string]string{"X-GitHub-Event": "push"}, + MatchQuery: map[string]string{"source": "github"}, + }, + }, + } + pb := &apiv1.Action{} + + applyActionExecTriggers(pb, cfg) + + require.Len(t, pb.ExecOnWebhooks, 1) + assert.Equal(t, cfg.ExecOnWebhook[0].MatchHeaders, pb.ExecOnWebhooks[0].MatchHeaders) + assert.Equal(t, cfg.ExecOnWebhook[0].MatchQuery, pb.ExecOnWebhooks[0].MatchQuery) +} + func TestGetActionsAndStart(t *testing.T) { cfg := config.DefaultConfig() @@ -335,3 +357,565 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard actionResult := buildAction(binding, rr) assert.Equal(t, expectedCanExec, actionResult.CanExec, message) } + +// buildViewPermissionTestConfig returns config and users for GHSA view-permission tests: +// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin". +func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) { + t.Helper() + cfg := config.DefaultConfig() + cfg.DefaultPermissions.View = false + cfg.DefaultPermissions.Exec = false + cfg.DefaultPermissions.Logs = false + + cfg.Actions = append(cfg.Actions, &config.Action{ + ID: "secret_action", + Title: "Secret Action", + Shell: "echo sensitive", + Icon: "🔒", + }) + + cfg.AccessControlLists = append(cfg.AccessControlLists, + &config.AccessControlList{ + Name: "restricted", + MatchUsernames: []string{"low"}, + AddToEveryAction: true, + Permissions: config.PermissionsList{View: false, Exec: false, Logs: false, Kill: false}, + }, + &config.AccessControlList{ + Name: "full", + MatchUsernames: []string{"admin"}, + AddToEveryAction: true, + Permissions: config.PermissionsList{View: true, Exec: true, Logs: true, Kill: true}, + }, + ) + + lowUser := &authpublic.AuthenticatedUser{Username: "low"} + lowUser.BuildUserAcls(cfg) + adminUser := &authpublic.AuthenticatedUser{Username: "admin"} + adminUser.BuildUserAcls(cfg) + return cfg, lowUser, adminUser +} + +// TestViewPermissionExcludedFromDashboard (GHSA: view permission) asserts that when a user has view: false, +// the default dashboard must not include that action. Covers GetDashboard not leaking action metadata. +func TestViewPermissionExcludedFromDashboard(t *testing.T) { + cfg, lowUser, _ := buildViewPermissionTestConfig(t) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + rr := &DashboardRenderRequest{ + AuthenticatedUser: lowUser, + cfg: cfg, + ex: ex, + } + db := buildDefaultDashboard(rr) + + bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents) + assert.NotContains(t, bindingIdsInDashboard, "secret_action", + "user with view:false must not see action in dashboard; got bindingIds: %v", bindingIdsInDashboard) +} + +// TestGetActionBindingDeniedWhenNoViewPermission (GHSA: view permission) asserts that GetActionBinding +// returns permission denied for a user with view: false. Covers GetActionBinding not exposing action details. +func TestGetActionBindingDeniedWhenNoViewPermission(t *testing.T) { + cfg, lowUser, _ := buildViewPermissionTestConfig(t) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + api := newServer(ex) + + _, err := api.getActionBindingResponse(lowUser, "secret_action") + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), + "user with view:false must get permission denied from GetActionBinding") +} + +// TestValidateArgumentTypeDeniesGuestsWhenLoginRequired (GHSA-f637-w7p2-m7fx) asserts that when +// guests must log in, ValidateArgumentType does not bypass dashboard access controls. +func TestValidateArgumentTypeDeniesGuestsWhenLoginRequired(t *testing.T) { + cfg := config.DefaultConfig() + cfg.AuthRequireGuestsToLogin = true + cfg.Actions = append(cfg.Actions, &config.Action{ + ID: "a1", + Title: "Probe", + Shell: "echo", + Arguments: []config.ActionArgument{ + {Name: "x", Type: "ascii"}, + }, + }) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + _, err := client.ValidateArgumentType(context.Background(), connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{ + BindingId: "a1", + ArgumentName: "x", + Value: "v", + Type: "ascii", + })) + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), + "guest must not call ValidateArgumentType when AuthRequireGuestsToLogin is true") +} + +// TestValidateArgumentTypeDeniedWithoutViewPermission (GHSA-f637-w7p2-m7fx) asserts ValidateArgumentType +// respects the same view ACL as GetActionBinding so the RPC cannot enumerate restricted actions. +func TestValidateArgumentTypeDeniedWithoutViewPermission(t *testing.T) { + cfg, _, _ := buildViewPermissionTestConfig(t) + cfg.AuthHttpHeaderUsername = "X-Ot-User" + for i := range cfg.Actions { + if cfg.Actions[i].ID == "secret_action" { + cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}} + break + } + } + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{ + BindingId: "secret_action", + ArgumentName: "target", + Value: "ok", + Type: "ascii", + }) + req.Header().Set("X-Ot-User", "low") + + _, err := client.ValidateArgumentType(context.Background(), req) + require.Error(t, err) + assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err), + "user with view:false must get permission denied from ValidateArgumentType") +} + +// TestValidateArgumentTypeAllowedWithViewPermission (GHSA-f637-w7p2-m7fx) asserts authenticated users +// with view access can still use ValidateArgumentType for argument validation. +func TestValidateArgumentTypeAllowedWithViewPermission(t *testing.T) { + cfg, _, _ := buildViewPermissionTestConfig(t) + cfg.AuthHttpHeaderUsername = "X-Ot-User" + for i := range cfg.Actions { + if cfg.Actions[i].ID == "secret_action" { + cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}} + break + } + } + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{ + BindingId: "secret_action", + ArgumentName: "target", + Value: "ok", + Type: "ascii", + }) + req.Header().Set("X-Ot-User", "admin") + + resp, err := client.ValidateArgumentType(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Msg) + assert.True(t, resp.Msg.Valid, "admin with view:true should get successful validation for a valid ascii value") +} + +// TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true +// still sees the action in the dashboard and can fetch it via GetActionBinding. +func TestViewPermissionAllowedSeesAction(t *testing.T) { + cfg, _, adminUser := buildViewPermissionTestConfig(t) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + api := newServer(ex) + + rr := &DashboardRenderRequest{ + AuthenticatedUser: adminUser, + cfg: cfg, + ex: ex, + } + db := buildDefaultDashboard(rr) + bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents) + assert.Contains(t, bindingIdsInDashboard, "secret_action", + "user with view:true must see action in dashboard; got bindingIds: %v", bindingIdsInDashboard) + + resp, err := api.getActionBindingResponse(adminUser, "secret_action") + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, resp.Action) + assert.Equal(t, "secret_action", resp.Action.BindingId) +} + +// TestViewPermissionExcludedFromCustomDashboard (issue #921) asserts that when a custom dashboard +// lists an action by title, users without view permission do not see that action (title or icon). +func TestViewPermissionExcludedFromCustomDashboard(t *testing.T) { + cfg, lowUser, _ := buildViewPermissionTestConfig(t) + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "Custom", + Contents: []*config.DashboardComponent{ + {Title: "Secret Action"}, + }, + }, + } + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + rr := &DashboardRenderRequest{ + AuthenticatedUser: lowUser, + cfg: cfg, + ex: ex, + } + dashboard := findDashboardByTitle(rr, "Custom") + require.NotNil(t, dashboard) + db := buildDashboardFromConfig(dashboard, rr) + require.NotNil(t, db) + + bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents) + assert.NotContains(t, bindingIdsInDashboard, "secret_action", + "user with view:false must not see action on custom dashboard; got bindingIds: %v", bindingIdsInDashboard) + assert.False(t, dashboardContentsContainForbiddenComponent(db.Contents, "Secret Action", "🔒"), + "user with view:false must not see Secret Action title or lock icon in custom dashboard") +} + +// TestViewPermissionExcludedFromEntityDashboard (GHSA: view permission) asserts that when a dashboard +// has an entity fieldset listing an action, users without view permission do not see that action. +func TestViewPermissionExcludedFromEntityDashboard(t *testing.T) { + entities.ClearEntitiesOfType("vp_entity_test") + defer entities.ClearEntitiesOfType("vp_entity_test") + entities.AddEntity("vp_entity_test", "1", map[string]any{"title": "Test Entity"}) + + cfg, lowUser, _ := buildViewPermissionTestConfig(t) + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "WithEntity", + Contents: []*config.DashboardComponent{ + { + Title: "Servers", Type: "fieldset", Entity: "vp_entity_test", + Contents: []*config.DashboardComponent{{Title: "Secret Action"}}, + }, + }, + }, + } + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + rr := &DashboardRenderRequest{ + AuthenticatedUser: lowUser, + cfg: cfg, + ex: ex, + } + dashboard := findDashboardByTitle(rr, "WithEntity") + require.NotNil(t, dashboard) + db := buildDashboardFromConfig(dashboard, rr) + require.NotNil(t, db) + + bindingIdsInDashboard := bindingIdsInDashboardContents(db.Contents) + assert.NotContains(t, bindingIdsInDashboard, "secret_action", + "user with view:false must not see action in entity fieldset; got bindingIds: %v", bindingIdsInDashboard) + assert.False(t, dashboardContentsContainForbiddenComponent(db.Contents, "Secret Action", "🔒"), + "user with view:false must not see Secret Action title or lock icon in entity dashboard") +} + +func bindingIdsInDashboardContents(contents []*apiv1.DashboardComponent) []string { + var ids []string + for _, c := range contents { + ids = append(ids, bindingIdsFromComponent(c)...) + } + return ids +} + +func bindingIdsFromComponent(c *apiv1.DashboardComponent) []string { + if c == nil { + return nil + } + var ids []string + if c.Action != nil && c.Action.BindingId != "" { + ids = append(ids, c.Action.BindingId) + } + return append(ids, bindingIdsInDashboardContents(c.Contents)...) +} + +func componentHasForbiddenTitleOrIcon(c *apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool { + return c != nil && (c.Title == forbiddenTitle || c.Icon == forbiddenIcon) +} + +func componentOrDescendantsContainForbidden(c *apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool { + if c == nil { + return false + } + if componentHasForbiddenTitleOrIcon(c, forbiddenTitle, forbiddenIcon) { + return true + } + return dashboardContentsContainForbiddenComponent(c.Contents, forbiddenTitle, forbiddenIcon) +} + +// dashboardContentsContainForbiddenComponent recursively walks contents and returns true if any +// component has Title == forbiddenTitle or Icon == forbiddenIcon. +func dashboardContentsContainForbiddenComponent(contents []*apiv1.DashboardComponent, forbiddenTitle, forbiddenIcon string) bool { + for _, c := range contents { + if componentOrDescendantsContainForbidden(c, forbiddenTitle, forbiddenIcon) { + return true + } + } + return false +} + +func TestOrderTopLevelDashboardComponents_RegularFieldsetsPreserveConfigOrder(t *testing.T) { + zebra := &apiv1.DashboardComponent{Title: "Zebra", Type: "fieldset", EntityType: ""} + alpha := &apiv1.DashboardComponent{Title: "Alpha", Type: "fieldset", EntityType: ""} + root := &apiv1.DashboardComponent{Title: "Actions", Type: "fieldset", EntityType: ""} + components := []*apiv1.DashboardComponent{zebra, alpha, root} + + out := orderTopLevelDashboardComponents(components, root) + + require.Len(t, out, 3) + assert.Same(t, zebra, out[0], "first must be Zebra (config order)") + assert.Same(t, alpha, out[1], "second must be Alpha (config order)") + assert.Same(t, root, out[2], "third must be root Actions fieldset") +} + +func TestOrderTopLevelDashboardComponents_SortablesSorted(t *testing.T) { + entityBeta := &apiv1.DashboardComponent{Title: "Beta", Type: "fieldset", EntityType: "server"} + entityAlpha := &apiv1.DashboardComponent{Title: "Alpha", Type: "fieldset", EntityType: "server"} + components := []*apiv1.DashboardComponent{entityBeta, entityAlpha} + + out := orderTopLevelDashboardComponents(components, nil) + + require.Len(t, out, 2) + assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title") + assert.Equal(t, "Beta", out[1].Title) +} + +// TestEventStreamACLNoLeakToUnauthorizedUser (GHSA-228v-wc5r-j8m7) asserts that EventStream +// does not send execution events or output chunks to users who are not allowed to view that action's logs. +func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) { + cfg, lowUser, adminUser := buildViewPermissionTestConfig(t) + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + api := newServer(ex) + + binding := ex.FindBindingByID("secret_action") + require.NotNil(t, binding, "secret_action binding must exist") + + clientLow, clientAdmin := addEventStreamTestClients(t, api, lowUser, adminUser) + defer removeEventStreamTestClients(api, clientLow, clientAdmin) + + runEventStreamTestExecution(t, ex, cfg, binding, adminUser) + adminEvents := drainEventStreamUntilFinished(clientAdmin.channel, 2*time.Second) + lowEvents := drainEventStreamWithTimeout(clientLow.channel, 50*time.Millisecond) + + assertEventStreamLowUserReceivesNothing(t, lowEvents) + assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents) +} + +func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) { + t.Helper() + clientLow := &streamingClient{ + channel: make(chan *apiv1.EventStreamResponse, 20), + AuthenticatedUser: lowUser, + } + clientAdmin := &streamingClient{ + channel: make(chan *apiv1.EventStreamResponse, 20), + AuthenticatedUser: adminUser, + } + api.streamingClientsMutex.Lock() + api.streamingClients[clientLow] = struct{}{} + api.streamingClients[clientAdmin] = struct{}{} + api.streamingClientsMutex.Unlock() + return clientLow, clientAdmin +} + +func removeEventStreamTestClients(api *oliveTinAPI, clientLow, clientAdmin *streamingClient) { + api.streamingClientsMutex.Lock() + delete(api.streamingClients, clientLow) + delete(api.streamingClients, clientAdmin) + api.streamingClientsMutex.Unlock() + close(clientLow.channel) + close(clientAdmin.channel) +} + +func runEventStreamTestExecution(t *testing.T, ex *executor.Executor, cfg *config.Config, binding *executor.ActionBinding, adminUser *authpublic.AuthenticatedUser) { + t.Helper() + execReq := &executor.ExecutionRequest{ + Binding: binding, + Arguments: map[string]string{}, + TrackingID: uuid.NewString(), + Cfg: cfg, + AuthenticatedUser: adminUser, + } + wg, _ := ex.ExecRequest(execReq) + wg.Wait() +} + +func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse { + var out []*apiv1.EventStreamResponse + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ev, finished := recvEventStreamOne(ch, 50*time.Millisecond) + if ev != nil { + out = append(out, ev) + } + if finished { + return out + } + } + return out +} + +func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) { + select { + case ev, ok := <-ch: + if !ok { + return nil, true + } + return ev, ev.GetExecutionFinished() != nil + case <-time.After(timeout): + return nil, true + } +} + +func eventStreamRecvResult(ev *apiv1.EventStreamResponse, ok bool) (*apiv1.EventStreamResponse, bool) { + if !ok { + return nil, true + } + return ev, false +} + +func recvEventStreamWithTimeoutOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) { + select { + case ev, ok := <-ch: + return eventStreamRecvResult(ev, ok) + case <-time.After(timeout): + return nil, true + } +} + +func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse { + var out []*apiv1.EventStreamResponse + for { + ev, done := recvEventStreamWithTimeoutOne(ch, timeout) + if done { + return out + } + out = append(out, ev) + } +} + +func assertEventStreamLowUserReceivesNothing(t *testing.T, lowEvents []*apiv1.EventStreamResponse) { + t.Helper() + for _, ev := range lowEvents { + assert.Nil(t, ev.GetExecutionStarted(), "low-privilege user must not receive ExecutionStarted") + assert.Nil(t, ev.GetExecutionFinished(), "low-privilege user must not receive ExecutionFinished") + assert.Nil(t, ev.GetOutputChunk(), "low-privilege user must not receive OutputChunk") + } + assert.Empty(t, lowEvents, "low-privilege user with Logs:false must not receive any execution events") +} + +func assertEventStreamAdminReceivesSecretActionEvents(t *testing.T, adminEvents []*apiv1.EventStreamResponse) { + t.Helper() + var gotStarted, gotFinished bool + for _, ev := range adminEvents { + if ev.GetExecutionStarted() != nil { + gotStarted = true + assert.Equal(t, "secret_action", ev.GetExecutionStarted().LogEntry.GetBindingId()) + } + if ev.GetExecutionFinished() != nil { + gotFinished = true + assert.Equal(t, "secret_action", ev.GetExecutionFinished().LogEntry.GetBindingId()) + } + } + assert.True(t, gotStarted, "admin must receive ExecutionStarted for secret_action") + assert.True(t, gotFinished, "admin must receive ExecutionFinished for secret_action") +} + +func TestExecutionStatusReturnsBackToDashboards(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{ + {Title: "Dashboard Action", Shell: "echo ok"}, + } + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "Ops", + Contents: []*config.DashboardComponent{ + {Title: "Dashboard Action"}, + }, + }, + } + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, binding) + + _, client := getNewTestServerAndClientWithExecutor(cfg, ex) + + startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{ + BindingId: binding.ID, + })) + require.NoError(t, err) + + statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{ + ExecutionTrackingId: startResp.Msg.ExecutionTrackingId, + })) + require.NoError(t, err) + require.NotNil(t, statusResp.Msg) + require.Len(t, statusResp.Msg.BackToDashboards, 1) + assert.Equal(t, "Ops", statusResp.Msg.BackToDashboards[0].Title) + assert.Equal(t, "/dashboards/Ops", statusResp.Msg.BackToDashboards[0].Path) +} + +func TestGetActionBindingReturnsBackToDashboards(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Actions = []*config.Action{ + {Title: "Dashboard Action", Shell: "echo ok"}, + } + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "Ops", + Contents: []*config.DashboardComponent{ + {Title: "Dashboard Action"}, + }, + }, + } + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, binding) + + _, client := getNewTestServerAndClientWithExecutor(cfg, ex) + + resp, err := client.GetActionBinding(context.Background(), connect.NewRequest(&apiv1.GetActionBindingRequest{ + BindingId: binding.ID, + })) + require.NoError(t, err) + require.NotNil(t, resp.Msg) + require.Len(t, resp.Msg.BackToDashboards, 1) + assert.Equal(t, "Ops", resp.Msg.BackToDashboards[0].Title) + assert.Equal(t, "/dashboards/Ops", resp.Msg.BackToDashboards[0].Path) +} + +func TestBuildActionIncludesGroups(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ActionGroups = map[string]*config.ActionGroup{ + "con2queue10": {MaxConcurrent: 2, QueueSize: 10}, + } + cfg.Actions = []*config.Action{ + {Title: "Long running action", Shell: "sleep 1", Groups: []string{"con2queue10", "missing"}}, + } + cfg.Sanitize() + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + binding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, binding) + + rr := &DashboardRenderRequest{cfg: cfg, ex: ex} + actionResult := buildAction(binding, rr) + + require.Len(t, actionResult.Groups, 2) + assert.Equal(t, "con2queue10", actionResult.Groups[0].Name) + assert.Equal(t, int32(2), actionResult.Groups[0].MaxConcurrent) + assert.Equal(t, int32(10), actionResult.Groups[0].QueueSize) + assert.Equal(t, "missing", actionResult.Groups[1].Name) + assert.Equal(t, int32(0), actionResult.Groups[1].MaxConcurrent) +} diff --git a/service/internal/api/dashboard_entities.go b/service/internal/api/dashboard_entities.go index 7bec464..d588a57 100644 --- a/service/internal/api/dashboard_entities.go +++ b/service/internal/api/dashboard_entities.go @@ -11,9 +11,8 @@ import ( func buildEntityFieldsets(entityTitle string, tpl *config.DashboardComponent, rr *DashboardRenderRequest) []*apiv1.DashboardComponent { ret := make([]*apiv1.DashboardComponent, 0) - entities := entities.GetEntityInstances(entityTitle) - - for _, ent := range entities { + orderedEntities := entities.GetEntityInstancesOrdered(entityTitle) + for _, ent := range orderedEntities { fs := buildEntityFieldset(tpl, ent, rr) if len(fs.Contents) > 0 { @@ -30,7 +29,7 @@ func buildEntityFieldset(component *config.DashboardComponent, ent *entities.Ent Type: "fieldset", Contents: removeFieldsetIfHasNoLinks(buildEntityFieldsetContents(component.Contents, ent, component.Entity, rr)), CssClass: tpl.ParseTemplateOfActionBeforeExec(component.CssClass, ent), - Action: rr.findAction(component.Title), + Action: rr.findActionForEntity(component.Title, ent), EntityType: component.Entity, EntityKey: ent.UniqueKey, } @@ -57,7 +56,7 @@ func buildEntityFieldsetContents(contents []*config.DashboardComponent, ent *ent for _, subitem := range contents { c := cloneItem(subitem, ent, entityType, rr) - log.Infof("cloneItem: %+v", c) + log.Tracef("cloneItem: %+v", c) if c != nil { ret = append(ret, c) @@ -83,8 +82,6 @@ func isLinkType(itemType string) bool { } func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clone *apiv1.DashboardComponent, rr *DashboardRenderRequest) *apiv1.DashboardComponent { - clone.Type = "link" - clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent) // Prefer an entity-specific action when available, but fall back to a // non-entity-scoped action with the same title. This allows inline actions // defined inside entity dashboards to work without requiring an explicit @@ -93,7 +90,11 @@ func cloneLinkItem(subitem *config.DashboardComponent, ent *entities.Entity, clo if action == nil { action = rr.findAction(subitem.Title) } - + if action == nil { + return nil + } + clone.Type = "link" + clone.Title = tpl.ParseTemplateOfActionBeforeExec(subitem.Title, ent) clone.Action = action return clone } diff --git a/service/internal/api/dashboard_entities_test.go b/service/internal/api/dashboard_entities_test.go new file mode 100644 index 0000000..718e35f --- /dev/null +++ b/service/internal/api/dashboard_entities_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/executor" +) + +func TestBuildEntityFieldsetDisplayRendersEntityHtmlTitleAndCssClass(t *testing.T) { + entities.ClearEntitiesOfType("html_display") + defer entities.ClearEntitiesOfType("html_display") + + entities.AddEntity("html_display", "0", map[string]any{ + "content": "
test
", + }) + + cfg := config.DefaultConfig() + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "Stream status", + Contents: []*config.DashboardComponent{ + { + Title: "Compare result", + Type: "fieldset", + Entity: "html_display", + Contents: []*config.DashboardComponent{ + { + Type: "display", + CssClass: "full_screen", + Title: "{{ html_display.content }}", + }, + }, + }, + }, + }, + } + + ex := executor.DefaultExecutor(cfg) + ex.RebuildActionMap() + + rr := &DashboardRenderRequest{ + cfg: cfg, + ex: ex, + } + + fieldsets := buildEntityFieldsets("html_display", cfg.Dashboards[0].Contents[0], rr) + require.Len(t, fieldsets, 1) + + display := findComponentByType(fieldsets[0].Contents, "display") + require.NotNil(t, display) + assert.Equal(t, "full_screen", display.CssClass) + assert.Equal(t, "
test
", display.Title) +} + +func findComponentByType(components []*apiv1.DashboardComponent, componentType string) *apiv1.DashboardComponent { + for _, component := range components { + if component.Type == componentType { + return component + } + + if found := findNestedComponent(component, componentType); found != nil { + return found + } + } + + return nil +} + +func findNestedComponent(component *apiv1.DashboardComponent, componentType string) *apiv1.DashboardComponent { + if len(component.Contents) == 0 { + return nil + } + + return findComponentByType(component.Contents, componentType) +} + +func TestGetDashboardEntityDisplayHtmlTitle(t *testing.T) { + entities.ClearEntitiesOfType("html_display") + defer entities.ClearEntitiesOfType("html_display") + + entities.AddEntity("html_display", "0", map[string]any{ + "content": "
test
", + }) + + cfg := config.DefaultConfig() + cfg.Dashboards = []*config.DashboardComponent{ + { + Title: "Html Dashboard", + Contents: []*config.DashboardComponent{ + { + Title: "Compare result", + Type: "fieldset", + Entity: "html_display", + Contents: []*config.DashboardComponent{ + { + Type: "display", + CssClass: "full_screen", + Title: "{{ html_display.content }}", + }, + }, + }, + }, + }, + } + + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + resp, err := client.GetDashboard(context.Background(), connect.NewRequest(&apiv1.GetDashboardRequest{ + Title: "Html Dashboard", + })) + require.NoError(t, err) + + display := findComponentByType(resp.Msg.Dashboard.Contents, "display") + require.NotNil(t, display) + assert.Equal(t, "full_screen", display.CssClass) + assert.Equal(t, "
test
", display.Title) +} diff --git a/service/internal/api/dashboards.go b/service/internal/api/dashboards.go index 11c6cf3..c1c51e6 100644 --- a/service/internal/api/dashboards.go +++ b/service/internal/api/dashboards.go @@ -2,8 +2,10 @@ package api import ( "sort" + "strconv" apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + acl "github.com/OliveTin/OliveTin/internal/acl" config "github.com/OliveTin/OliveTin/internal/config" entities "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/tpl" @@ -110,9 +112,10 @@ func buildDashboardFromConfig(dashboard *config.DashboardComponent, rr *Dashboar } func buildDashboardFromConfigWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.Dashboard { + contents, root := getDashboardComponentContentsWithEntity(dashboard, rr, entity) return &apiv1.Dashboard{ Title: dashboard.Title, - Contents: sortActions(removeNulls(getDashboardComponentContentsWithEntity(dashboard, rr, entity))), + Contents: orderTopLevelDashboardComponents(removeNulls(contents), root), } } @@ -130,43 +133,71 @@ func buildDefaultDashboard(rr *DashboardRenderRequest) *apiv1.Dashboard { } for _, binding := range rr.ex.MapActionBindings { - if binding.Action.Hidden { + if binding == nil || binding.Action == nil || binding.Action.Hidden { continue } - if binding.IsOnDashboard { + if binding.IsOnConfiguredDashboard() { + continue + } + + if !acl.IsAllowedView(rr.cfg, rr.AuthenticatedUser, binding.Action) { continue } action := buildAction(binding, rr) + if action == nil { + continue + } - fieldset.Contents = append(fieldset.Contents, &apiv1.DashboardComponent{ + comp := &apiv1.DashboardComponent{ Type: "link", Title: action.Title, Icon: action.Icon, Action: action, - }) + } + if binding.Entity != nil { + comp.EntityKey = binding.Entity.UniqueKey + } + fieldset.Contents = append(fieldset.Contents, comp) } if len(fieldset.Contents) > 0 { - fieldset.Contents = sortActions(fieldset.Contents) + fieldset.Contents = sortDashboardComponents(fieldset.Contents) db.Contents = append(db.Contents, fieldset) } return db } -func sortActions(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent { +func entityKeyLess(a, b string) bool { + ai, errA := strconv.ParseInt(a, 10, 64) + bi, errB := strconv.ParseInt(b, 10, 64) + if errA == nil && errB == nil { + return ai < bi + } + return a < b +} + +//gocyclo:ignore +func sortDashboardComponents(components []*apiv1.DashboardComponent) []*apiv1.DashboardComponent { sort.Slice(components, func(i, j int) bool { if components[i].Action == nil || components[j].Action == nil { + if components[i].EntityKey != "" && components[j].EntityKey != "" && + components[i].EntityKey != components[j].EntityKey { + return entityKeyLess(components[i].EntityKey, components[j].EntityKey) + } + return components[i].Title < components[j].Title } - if components[i].Action.Order == components[j].Action.Order { - return components[i].Action.Title < components[j].Action.Title - } else { + if components[i].Action.Order != components[j].Action.Order { return components[i].Action.Order < components[j].Action.Order } + if components[i].EntityKey != components[j].EntityKey { + return entityKeyLess(components[i].EntityKey, components[j].EntityKey) + } + return components[i].Action.Title < components[j].Action.Title }) return components @@ -186,7 +217,58 @@ func removeNulls(components []*apiv1.DashboardComponent) []*apiv1.DashboardCompo return ret } -func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) []*apiv1.DashboardComponent { +func isNonEntityFieldset(component *apiv1.DashboardComponent) bool { + return component != nil && component.Type == "fieldset" && component.EntityType == "" +} + +func isRegularFieldset(component *apiv1.DashboardComponent, root *apiv1.DashboardComponent) bool { + if !isNonEntityFieldset(component) { + return false + } + return root == nil || component != root +} + +func partitionTopLevelComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) (regular, sortables []*apiv1.DashboardComponent, isRegular []bool) { + regular = make([]*apiv1.DashboardComponent, 0) + sortables = make([]*apiv1.DashboardComponent, 0) + isRegular = make([]bool, len(components)) + for i, c := range components { + anchor := isRegularFieldset(c, root) + isRegular[i] = anchor + if anchor { + regular = append(regular, c) + } else { + sortables = append(sortables, c) + } + } + return regular, sortables, isRegular +} + +func mergeOrderedTopLevelComponents(regular, sortables []*apiv1.DashboardComponent, isRegular []bool) []*apiv1.DashboardComponent { + out := make([]*apiv1.DashboardComponent, 0, len(isRegular)) + regIdx, sortIdx := 0, 0 + for _, anchor := range isRegular { + if anchor { + out = append(out, regular[regIdx]) + regIdx++ + } else { + out = append(out, sortables[sortIdx]) + sortIdx++ + } + } + return out +} + +func orderTopLevelDashboardComponents(components []*apiv1.DashboardComponent, root *apiv1.DashboardComponent) []*apiv1.DashboardComponent { + if len(components) == 0 { + return components + } + regular, sortables, isRegular := partitionTopLevelComponents(components, root) + sortDashboardComponents(sortables) + return mergeOrderedTopLevelComponents(regular, sortables, isRegular) +} + +func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) ([]*apiv1.DashboardComponent, *apiv1.DashboardComponent) { ret := make([]*apiv1.DashboardComponent, 0) rootFieldset := createRootFieldset() @@ -194,7 +276,11 @@ func getDashboardComponentContentsWithEntity(dashboard *config.DashboardComponen processDashboardSubitemWithEntity(subitem, rr, &ret, rootFieldset, entity) } - return appendRootFieldsetIfNeeded(ret, rootFieldset) + if len(rootFieldset.Contents) > 0 { + ret = append(ret, rootFieldset) + return ret, rootFieldset + } + return ret, nil } func createRootFieldset() *apiv1.DashboardComponent { @@ -205,31 +291,39 @@ func createRootFieldset() *apiv1.DashboardComponent { } } +func appendComponentIfNotNil(components *[]*apiv1.DashboardComponent, comp *apiv1.DashboardComponent) { + if comp != nil { + *components = append(*components, comp) + } +} + +func getDashboardComponentOrNil(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent { + if len(subitem.Contents) == 0 && rr.findActionForEntity(subitem.Title, entity) == nil { + if !isAllowedType(subitem.Type) { + return nil + } + } + return buildDashboardComponentSimpleWithEntity(subitem, rr, entity) +} + func processDashboardSubitemWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, ret *[]*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent, entity *entities.Entity) { if subitem.Type != "fieldset" { - rootFieldset.Contents = append(rootFieldset.Contents, buildDashboardComponentSimpleWithEntity(subitem, rr, entity)) + appendComponentIfNotNil(&rootFieldset.Contents, getDashboardComponentOrNil(subitem, rr, entity)) return } if subitem.Entity != "" { *ret = append(*ret, buildEntityFieldsets(subitem.Entity, subitem, rr)...) } else { - *ret = append(*ret, buildDashboardComponentSimpleWithEntity(subitem, rr, entity)) + appendComponentIfNotNil(ret, getDashboardComponentOrNil(subitem, rr, entity)) } } -func appendRootFieldsetIfNeeded(ret []*apiv1.DashboardComponent, rootFieldset *apiv1.DashboardComponent) []*apiv1.DashboardComponent { - if len(rootFieldset.Contents) > 0 { - ret = append(ret, rootFieldset) - } - return ret -} - func buildDashboardComponentSimpleWithEntity(subitem *config.DashboardComponent, rr *DashboardRenderRequest, entity *entities.Entity) *apiv1.DashboardComponent { var contents []*apiv1.DashboardComponent if len(subitem.Contents) > 0 { - contents = getDashboardComponentContentsWithEntity(subitem, rr, entity) + contents, _ = getDashboardComponentContentsWithEntity(subitem, rr, entity) } action := rr.findActionForEntity(subitem.Title, entity) diff --git a/service/internal/api/local_user_login.go b/service/internal/api/local_user_login.go index db6e99e..64159c2 100644 --- a/service/internal/api/local_user_login.go +++ b/service/internal/api/local_user_login.go @@ -1,6 +1,7 @@ package api import ( + "errors" "runtime" config "github.com/OliveTin/OliveTin/internal/config" @@ -8,6 +9,12 @@ import ( log "github.com/sirupsen/logrus" ) +var ErrArgon2Busy = errors.New("too many concurrent password operations") + +const argon2MaxConcurrent = 10 + +var argon2Sem = make(chan struct{}, argon2MaxConcurrent) + var defaultParams = argon2id.Params{ Memory: 64 * 1024, Iterations: 4, @@ -17,10 +24,16 @@ var defaultParams = argon2id.Params{ } func CreateHash(password string) (string, error) { + select { + case argon2Sem <- struct{}{}: + defer func() { <-argon2Sem }() + default: + return "", ErrArgon2Busy + } hash, err := argon2id.CreateHash(password, &defaultParams) if err != nil { - log.Fatal("Error creating hash: ", err) + log.Warnf("Error creating hash: %v", err) return "", err } @@ -31,37 +44,49 @@ func createHash(password string) (string, error) { return CreateHash(password) } -func comparePasswordAndHash(password, hash string) bool { +func comparePasswordAndHash(password, hash string) (bool, error) { + select { + case argon2Sem <- struct{}{}: + defer func() { <-argon2Sem }() + default: + return false, ErrArgon2Busy + } match, err := argon2id.ComparePasswordAndHash(password, hash) if err != nil { log.Errorf("Error comparing password and hash: %v", err) + return false, nil + } + + return match, nil +} + +func isLocalInteractiveLoginDisabledForUser(cfg *config.Config, username string) bool { + user := cfg.FindUserByUsername(username) + if user == nil { return false } - return match + return user.Password == "" } -func checkUserPassword(cfg *config.Config, username, password string) bool { - for _, user := range cfg.AuthLocalUsers.Users { - if user.Username == username { - match := comparePasswordAndHash(password, user.Password) - - if match { - return true - } else { - log.WithFields(log.Fields{ - "username": username, - }).Warn("Password does not match for user") - - return false - } - } +func checkUserPassword(cfg *config.Config, username, password string) (bool, error) { + user := cfg.FindUserByUsername(username) + if user == nil { + log.WithFields(log.Fields{"username": username}).Warn("Failed to check password for user, as username was not found") + return false, nil } - - log.WithFields(log.Fields{ - "username": username, - }).Warn("Failed to check password for user, as username was not found") - - return false + return comparePasswordAndLogResult(password, user.Password, username) +} + +func comparePasswordAndLogResult(password, hash, username string) (bool, error) { + match, err := comparePasswordAndHash(password, hash) + if err != nil { + return false, err + } + if !match { + log.WithFields(log.Fields{"username": username}).Warn("Password does not match for user") + return false, nil + } + return true, nil } diff --git a/service/internal/api/local_user_login_test.go b/service/internal/api/local_user_login_test.go new file mode 100644 index 0000000..8d27168 --- /dev/null +++ b/service/internal/api/local_user_login_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "context" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func TestLocalUserLoginRejectsUserWithNoPassword(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "onlykey", + ApiKey: "k", + Password: "", + }} + + ts, client := getNewTestServerAndClient(cfg) + defer ts.Close() + + resp, err := client.LocalUserLogin(context.Background(), connect.NewRequest(&apiv1.LocalUserLoginRequest{ + Username: "onlykey", + Password: "anything", + })) + require.NoError(t, err) + assert.False(t, resp.Msg.GetSuccess()) +} diff --git a/service/internal/auth/authcheck.go b/service/internal/auth/authcheck.go index 3b612b9..674906a 100644 --- a/service/internal/auth/authcheck.go +++ b/service/internal/auth/authcheck.go @@ -14,6 +14,7 @@ import ( var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{ checkUserFromHeaders, checkUserFromLocalSession, + checkUserFromLocalBearerApiKey, otjwt.CheckUserFromJwtHeader, otjwt.CheckUserFromJwtCookie, } diff --git a/service/internal/auth/authpublic/authenticateduser.go b/service/internal/auth/authpublic/authenticateduser.go index e4ab6f0..077f220 100644 --- a/service/internal/auth/authpublic/authenticateduser.go +++ b/service/internal/auth/authpublic/authenticateduser.go @@ -76,8 +76,9 @@ func (u *AuthenticatedUser) BuildUserAcls(cfg *config.Config) { func getEffectivePolicy(cfg *config.Config, u *AuthenticatedUser) *config.ConfigurationPolicy { ret := &config.ConfigurationPolicy{ - ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics, - ShowLogList: cfg.DefaultPolicy.ShowLogList, + ShowDiagnostics: cfg.DefaultPolicy.ShowDiagnostics, + ShowLogList: cfg.DefaultPolicy.ShowLogList, + ShowVersionNumber: cfg.DefaultPolicy.ShowVersionNumber, } for _, acl := range cfg.AccessControlLists { @@ -98,5 +99,9 @@ func buildConfigurationPolicy(ret *config.ConfigurationPolicy, policy config.Con ret.ShowLogList = policy.ShowLogList } + if policy.ShowVersionNumber { + ret.ShowVersionNumber = policy.ShowVersionNumber + } + return ret } diff --git a/service/internal/auth/local_bearer.go b/service/internal/auth/local_bearer.go new file mode 100644 index 0000000..05a1979 --- /dev/null +++ b/service/internal/auth/local_bearer.go @@ -0,0 +1,109 @@ +package auth + +import ( + "crypto/subtle" + "strings" + + types "github.com/OliveTin/OliveTin/internal/auth/authpublic" + "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +const localBearerScheme = "Bearer" + +func constantTimeEqualString(a, b string) bool { + if len(a) != len(b) { + return false + } + + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func bearerTokenFromAuthorizationHeader(authz string) (string, bool) { + idx := strings.IndexByte(authz, ' ') + if idx <= 0 { + return "", false + } + + if !strings.EqualFold(authz[:idx], localBearerScheme) { + return "", false + } + + token := strings.TrimSpace(authz[idx+1:]) + if token == "" { + return "", false + } + + return token, true +} + +func localUserHasAPIKey(user *config.LocalUser) bool { + return user != nil && user.ApiKey != "" +} + +func findLocalUserByAPIKey(cfg *config.Config, token string) *config.LocalUser { + for _, user := range cfg.AuthLocalUsers.Users { + if !localUserHasAPIKey(user) { + continue + } + + if constantTimeEqualString(token, user.ApiKey) { + return user + } + } + + return nil +} + +func localBearerAuthorizationHasEmptyCredential(authz string) bool { + idx := strings.IndexByte(authz, ' ') + return idx > 0 && + strings.EqualFold(authz[:idx], localBearerScheme) && + strings.TrimSpace(authz[idx+1:]) == "" +} + +func logLocalBearerAPIKeyParseFailure(authz string) { + if strings.TrimSpace(authz) == "" { + return + } + + if localBearerAuthorizationHasEmptyCredential(authz) { + log.Debugf("Local bearer API key: rejected (empty credential after Bearer prefix)") + return + } + + log.Tracef("Local bearer API key: skipped (Authorization is not a Bearer token)") +} + +func checkUserFromLocalBearerApiKey(context *types.AuthCheckingContext) *types.AuthenticatedUser { + if !context.Config.AuthLocalUsers.Enabled { + log.Tracef("Local bearer API key: skipped (authLocalUsers disabled)") + return nil + } + + authz := context.Request.Header.Get("Authorization") + token, ok := bearerTokenFromAuthorizationHeader(authz) + if !ok { + logLocalBearerAPIKeyParseFailure(authz) + return nil + } + + log.Debugf("Local bearer API key: checking configured local user API keys") + + user := findLocalUserByAPIKey(context.Config, token) + if user == nil { + log.Debugf("Local bearer API key: rejected (no matching local user)") + return nil + } + + log.WithFields(log.Fields{ + "username": user.Username, + "usergroup": user.Usergroup, + }).Debugf("Local bearer API key: authenticated") + + return &types.AuthenticatedUser{ + Username: user.Username, + UsergroupLine: user.Usergroup, + Provider: "local", + } +} diff --git a/service/internal/auth/local_bearer_test.go b/service/internal/auth/local_bearer_test.go new file mode 100644 index 0000000..65ce8c5 --- /dev/null +++ b/service/internal/auth/local_bearer_test.go @@ -0,0 +1,106 @@ +package auth + +import ( + "net/http/httptest" + "testing" + + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + Usergroup: "bots", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "bearer secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + user := checkUserFromLocalBearerApiKey(ctx) + require.NotNil(t, user) + assert.Equal(t, "bot", user.Username) + assert.Equal(t, "bots", user.UsergroupLine) + assert.Equal(t, "local", user.Provider) +} + +func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + Usergroup: "bots", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + user := checkUserFromLocalBearerApiKey(ctx) + require.NotNil(t, user) + assert.Equal(t, "bot", user.Username) + assert.Equal(t, "bots", user.UsergroupLine) + assert.Equal(t, "local", user.Provider) +} + +func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer wrong") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} + +func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = false + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} + +func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) { + t.Parallel() + + cfg := config.DefaultConfig() + cfg.AuthLocalUsers.Enabled = true + cfg.AuthLocalUsers.Users = []*config.LocalUser{{ + Username: "bot", + ApiKey: "secret-api-key", + }} + + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "secret-api-key") + + ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} + assert.Nil(t, checkUserFromLocalBearerApiKey(ctx)) +} diff --git a/service/internal/auth/otjwt/jwt.go b/service/internal/auth/otjwt/jwt.go index 103224a..2b06da5 100644 --- a/service/internal/auth/otjwt/jwt.go +++ b/service/internal/auth/otjwt/jwt.go @@ -33,6 +33,13 @@ func parseJwtToken(cfg *config.Config, jwtString string) (*jwt.Token, error) { return parseJwtTokenWithHMAC(cfg, jwtString) } +func parserOptionsWithAudience(cfg *config.Config) []jwt.ParserOption { + if cfg.AuthJwtAud == "" { + return nil + } + return []jwt.ParserOption{jwt.WithAudience(cfg.AuthJwtAud)} +} + func getClaimsFromJwtToken(cfg *config.Config, jwtString string) (jwt.MapClaims, error) { token, err := parseJwtToken(cfg, jwtString) @@ -56,7 +63,8 @@ func parseJwtTokenWithRemoteKey(cfg *config.Config, jwtToken string) (*jwt.Token return nil, err } - return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, jwt.WithAudience(cfg.AuthJwtAud)) + opts := parserOptionsWithAudience(cfg) + return jwt.Parse(jwtToken, jwksVerifier.Keyfunc, opts...) } var ( @@ -148,24 +156,30 @@ func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token return nil, err } - return jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) { + keyFunc := func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("parseJwt expected token algorithm RSA but got: %v", token.Header["alg"]) } return pubKey, nil - }) + } + + opts := parserOptionsWithAudience(cfg) + return jwt.Parse(jwtString, keyFunc, opts...) } // Hash-based Message Authentication Code func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, error) { - return jwt.Parse(jwtString, func(token *jwt.Token) (interface{}, error) { + keyFunc := func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"]) } return []byte(cfg.AuthJwtHmacSecret), nil - }) + } + + opts := parserOptionsWithAudience(cfg) + return jwt.Parse(jwtString, keyFunc, opts...) } func lookupClaimValueOrDefault(claims jwt.MapClaims, key string, def string) string { diff --git a/service/internal/auth/otjwt/jwt_test.go b/service/internal/auth/otjwt/jwt_test.go index d3f48a8..ed4e3af 100644 --- a/service/internal/auth/otjwt/jwt_test.go +++ b/service/internal/auth/otjwt/jwt_test.go @@ -66,12 +66,19 @@ func newMux() *http.ServeMux { } func createJWTTokenWithExpiration(t *testing.T, privateKey *rsa.PrivateKey, expire int64) string { + return createJWTTokenWithExpirationAndAudience(t, privateKey, expire, "") +} + +func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.PrivateKey, expire int64, audience string) string { token := jwt.New(jwt.SigningMethodRS256) claims := token.Claims.(jwt.MapClaims) claims["nbf"] = time.Now().Unix() - 1000 claims["exp"] = time.Now().Unix() + expire claims["sub"] = "test" claims["olivetinGroup"] = "test" + if audience != "" { + claims["aud"] = audience + } tokenStr, err := token.SignedString(privateKey) if err != nil { @@ -108,6 +115,10 @@ func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) { } func testJwkValidation(t *testing.T, expire int64, expectCode int) { + testJwkValidationWithAudience(t, expire, expectCode, "", "") +} + +func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) { privateKey, publicKeyPath := createKeys(t) defer os.Remove(publicKeyPath) @@ -116,8 +127,9 @@ func testJwkValidation(t *testing.T, expire int64, expectCode int) { cfg.AuthJwtClaimUsername = "sub" cfg.AuthJwtClaimUserGroup = "olivetinGroup" cfg.AuthJwtHeader = "Authorization" + cfg.AuthJwtAud = configAudience - tokenStr := createJWTTokenWithExpiration(t, privateKey, expire) + tokenStr := createJWTTokenWithExpirationAndAudience(t, privateKey, expire, tokenAudience) handler := setupJWTTestHandler(t, cfg) srv := httptest.NewServer(handler) @@ -135,6 +147,14 @@ func TestJWTSignatureVerificationFails(t *testing.T) { testJwkValidation(t, -500, 403) } +func TestJWTAudienceValidationRejectsWrongAudience(t *testing.T) { + testJwkValidationWithAudience(t, 1000, 403, "expected-audience", "wrong-audience") +} + +func TestJWTAudienceValidationAcceptsCorrectAudience(t *testing.T) { + testJwkValidationWithAudience(t, 1000, 200, "expected-audience", "expected-audience") +} + func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups interface{}) string { token := jwt.New(jwt.SigningMethodRS256) claims := token.Claims.(jwt.MapClaims) diff --git a/service/internal/auth/otoauth2/restapi_auth_oauth2.go b/service/internal/auth/otoauth2/restapi_auth_oauth2.go index 1dea7d0..351a13b 100644 --- a/service/internal/auth/otoauth2/restapi_auth_oauth2.go +++ b/service/internal/auth/otoauth2/restapi_auth_oauth2.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "os" + "sync" "time" authTypes "github.com/OliveTin/OliveTin/internal/auth/authpublic" @@ -21,6 +22,7 @@ import ( type OAuth2Handler struct { cfg *config.Config + mu sync.RWMutex registeredStates map[string]*oauth2State registeredProviders map[string]*oauth2.Config } @@ -108,14 +110,20 @@ func randString(nByte int) (string, error) { return base64.URLEncoding.EncodeToString(b), nil } +func (h *OAuth2Handler) cookieSecure(r *http.Request) bool { + useTLS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + return useTLS || h.cfg.Security.ForceSecureCookies +} + func (h *OAuth2Handler) setOAuthCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { cookie := &http.Cookie{ Name: name, Value: value, MaxAge: 900, // 15 minutes - Secure: r.TLS != nil, + Secure: h.cookieSecure(r), HttpOnly: true, Path: "/", + SameSite: http.SameSiteLaxMode, } http.SetCookie(w, cookie) @@ -138,11 +146,13 @@ func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) return } + h.mu.Lock() h.registeredStates[state] = &oauth2State{ providerConfig: provider, providerName: providerName, Username: "", } + h.mu.Unlock() h.setOAuthCallbackCookie(w, r, "olivetin-sid-oauth", state) @@ -171,7 +181,9 @@ func (h *OAuth2Handler) checkOAuthCallbackCookie(w http.ResponseWriter, r *http. return nil, state, false } + h.mu.RLock() registeredState, ok := h.registeredStates[state] + h.mu.RUnlock() if !ok { log.Errorf("State not found in server: %v", state) http.Error(w, "State not found in server", http.StatusBadRequest) @@ -281,8 +293,10 @@ func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Reque userInfoClient := h.createUserInfoClient(ctx, registeredState.providerConfig, tok, clientSettings) userinfo := getUserInfo(h.cfg, userInfoClient, providerConfig) + h.mu.Lock() h.registeredStates[state].Username = userinfo.Username h.registeredStates[state].Usergroup = h.computeUsergroup(userinfo, providerConfig) + h.mu.Unlock() http.Redirect(w, r, "/", http.StatusFound) } @@ -360,34 +374,42 @@ func getDataField(data map[string]any, field string) string { return stringVal } +func (h *OAuth2Handler) lookupOAuth2UserByState(state string) (*authTypes.AuthenticatedUser, bool) { + h.mu.RLock() + serverState, found := h.registeredStates[state] + if !found { + h.mu.RUnlock() + return nil, false + } + user := &authTypes.AuthenticatedUser{ + Username: serverState.Username, + UsergroupLine: serverState.Usergroup, + Provider: "oauth2", + SID: state, + } + h.mu.RUnlock() + return user, true +} + +func (h *OAuth2Handler) RevokeSession(sid string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.registeredStates, sid) +} + func (h *OAuth2Handler) CheckUserFromOAuth2Cookie(context *authTypes.AuthCheckingContext) *authTypes.AuthenticatedUser { cookie, err := context.Request.Cookie("olivetin-sid-oauth") - - user := &authTypes.AuthenticatedUser{} - - if err != nil { + if err != nil || cookie.Value == "" { return nil } - if cookie.Value == "" { - return nil - } - - serverState, found := h.registeredStates[cookie.Value] - + user, found := h.lookupOAuth2UserByState(cookie.Value) if !found { log.WithFields(log.Fields{ "sid": cookie.Value, "provider": "oauth2", }).Warnf("Stale session") - return nil } - - user.Username = serverState.Username - user.UsergroupLine = serverState.Usergroup - user.Provider = "oauth2" - user.SID = cookie.Value - return user } diff --git a/service/internal/auth/sessions.go b/service/internal/auth/sessions.go index f11fc8a..c8d7416 100644 --- a/service/internal/auth/sessions.go +++ b/service/internal/auth/sessions.go @@ -25,8 +25,9 @@ type SessionStorage struct { } var ( - sessionStorage *SessionStorage - sessionStorageMutex sync.RWMutex + sessionStorage *SessionStorage + sessionStorageMutex sync.RWMutex + oauth2SessionRevoker func(sid string) ) func init() { @@ -58,6 +59,38 @@ func RegisterUserSession(cfg *config.Config, provider string, sid string, userna saveUserSessions(cfg) } +// RegisterOAuth2SessionRevoker registers a callback to revoke OAuth2 sessions on logout. +// OAuth2 uses its own session storage; the API calls this when provider is oauth2. +func RegisterOAuth2SessionRevoker(fn func(sid string)) { + oauth2SessionRevoker = fn +} + +// RevokeSessionForProvider invalidates the session for the given provider and SID (e.g. on logout). +// Local auth uses shared SessionStorage; OAuth2 uses a separate storage and revoker. +func RevokeSessionForProvider(cfg *config.Config, provider string, sid string) { + if sid == "" { + return + } + if provider == "oauth2" && oauth2SessionRevoker != nil { + oauth2SessionRevoker(sid) + return + } + RevokeUserSession(cfg, provider, sid) +} + +// RevokeUserSession removes a session from storage so it can no longer be used (e.g. on logout). +func RevokeUserSession(cfg *config.Config, provider string, sid string) { + sessionStorageMutex.Lock() + defer sessionStorageMutex.Unlock() + + if sessionStorage.Providers[provider] != nil { + delete(sessionStorage.Providers[provider].Sessions, sid) + if cfg != nil { + saveUserSessions(cfg) + } + } +} + // GetUserSession retrieves a user session func GetUserSession(provider string, sid string) *UserSession { sessionStorageMutex.Lock() diff --git a/service/internal/config/config.go b/service/internal/config/config.go index de5bdcc..9a05ec7 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -4,6 +4,9 @@ import ( "fmt" ) +// ReservedArgumentNamePrefix is reserved for OliveTin-injected system arguments. +const ReservedArgumentNamePrefix = "ot_" + // Action represents the core functionality of OliveTin - commands that show up // as buttons in the UI. type Action struct { @@ -27,9 +30,19 @@ type Action struct { MaxConcurrent int `koanf:"maxConcurrent"` MaxRate []RateSpec `koanf:"maxRate"` Arguments []ActionArgument `koanf:"arguments"` + OnClick string `koanf:"onclick"` PopupOnStart string `koanf:"popupOnStart"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` EnabledExpression string `koanf:"enabledExpression"` + Groups []string `koanf:"groups"` + Justification bool `koanf:"justification"` +} + +// ActionGroup defines shared limits and metadata for a set of actions. +type ActionGroup struct { + MaxConcurrent int `koanf:"maxConcurrent"` + QueueSize int `koanf:"queueSize"` + Icon string `koanf:"icon"` } // ActionArgument objects appear on Actions. @@ -60,14 +73,15 @@ type RateSpec struct { // WebhookConfig defines configuration for generic webhook triggers. type WebhookConfig struct { - Secret string `koanf:"secret"` // Optional: secret for signature verification - AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none" - AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature") - MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers - MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath") - MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters - Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions - Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr") + Secret string `koanf:"secret"` // Optional: secret for signature verification + AuthType string `koanf:"authType"` // Optional: "hmac-sha256", "hmac-sha1", "bearer", "basic", "none" + AuthHeader string `koanf:"authHeader"` // Optional: custom header name for auth (default: "X-Webhook-Signature") + MatchHeaders map[string]string `koanf:"matchHeaders"` // Match HTTP headers + MatchPath string `koanf:"matchPath"` // JSONPath expression to match in request body (format: "jsonpath=value" or just "jsonpath") + MatchQuery map[string]string `koanf:"matchQuery"` // Match URL query parameters + Extract map[string]string `koanf:"extract"` // Map action argument names to JSONPath expressions + Template string `koanf:"template"` // Optional: template name (e.g., "github-push", "github-pr") + Justification string `koanf:"justification"` // Optional JSONPath to extract justification from webhook body } // Entity represents a "thing" that can have multiple actions associated with it. @@ -98,8 +112,9 @@ type AccessControlList struct { // ConfigurationPolicy defines global settings which are overridden with an ACL. type ConfigurationPolicy struct { - ShowDiagnostics bool `koanf:"showDiagnostics"` - ShowLogList bool `koanf:"showLogList"` + ShowDiagnostics bool `koanf:"showDiagnostics"` + ShowLogList bool `koanf:"showLogList"` + ShowVersionNumber bool `koanf:"showVersionNumber"` } type PrometheusConfig struct { @@ -107,6 +122,16 @@ type PrometheusConfig struct { DefaultGoMetrics bool `koanf:"defaultGoMetrics"` } +// SecurityConfig allows users to fine tune the security related HTTP headers and cookie options. +type SecurityConfig struct { + HeaderContentSecurityPolicy bool `koanf:"headerContentSecurityPolicy"` + ContentSecurityPolicy string `koanf:"contentSecurityPolicy"` + HeaderXContentTypeOptions bool `koanf:"headerXContentTypeOptions"` + HeaderXFrameOptions bool `koanf:"headerXFrameOptions"` + XFrameOptions string `koanf:"xFrameOptions"` + ForceSecureCookies bool `koanf:"forceSecureCookies"` +} + // Config is the global config used through the whole app. type Config struct { UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"` @@ -120,6 +145,7 @@ type Config struct { LogLevel string `koanf:"logLevel"` LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"` LogHistoryPageSize int64 `koanf:"logHistoryPageSize"` + ActionGroups map[string]*ActionGroup `koanf:"actionGroups"` Actions []*Action `koanf:"actions"` Entities []*EntityFile `koanf:"entities"` Dashboards []*DashboardComponent `koanf:"dashboards"` @@ -153,6 +179,7 @@ type Config struct { WebUIDir string `koanf:"webUIDir"` CronSupportForSeconds bool `koanf:"cronSupportForSeconds"` SectionNavigationStyle string `koanf:"sectionNavigationStyle"` + DefaultOnClick string `koanf:"defaultOnClick"` DefaultPopupOnStart string `koanf:"defaultPopupOnStart"` InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"` InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"` @@ -160,7 +187,9 @@ type Config struct { InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"` InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"` Prometheus PrometheusConfig `koanf:"prometheus"` + Security SecurityConfig `koanf:"security"` SaveLogs SaveLogsConfig `koanf:"saveLogs"` + ServiceLogs ServiceLogsConfig `koanf:"serviceLogs"` DefaultIconForActions string `koanf:"defaultIconForActions"` DefaultIconForDirectories string `koanf:"defaultIconForDirectories"` DefaultIconForBack string `koanf:"defaultIconForBack"` @@ -183,6 +212,7 @@ type LocalUser struct { Username string `koanf:"username"` Usergroup string `koanf:"usergroup"` Password string `koanf:"password"` + ApiKey string `koanf:"apiKey"` } type OAuth2Provider struct { @@ -214,6 +244,10 @@ type SaveLogsConfig struct { OutputDirectory string `koanf:"outputDirectory"` } +type ServiceLogsConfig struct { + Directory string `koanf:"directory"` +} + type LogDebugOptions struct { SingleFrontendRequests bool `koanf:"singleFrontendRequests"` SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"` @@ -261,6 +295,7 @@ func DefaultConfigWithBasePort(basePort int) *Config { config.WebUIDir = "./webui" config.CronSupportForSeconds = false config.SectionNavigationStyle = "sidebar" + config.DefaultOnClick = "nothing" config.DefaultPopupOnStart = "nothing" config.InsecureAllowDumpVars = false config.InsecureAllowDumpSos = false @@ -268,7 +303,12 @@ func DefaultConfigWithBasePort(basePort int) *Config { config.InsecureAllowDumpJwtClaims = false config.Prometheus.Enabled = false config.Prometheus.DefaultGoMetrics = false - config.DefaultIconForActions = "😀" + config.Security.HeaderContentSecurityPolicy = true + config.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault + config.Security.HeaderXContentTypeOptions = true + config.Security.HeaderXFrameOptions = true + config.Security.XFrameOptions = "DENY" + config.DefaultIconForActions = "hugeicons:CommandLineIcon" config.DefaultIconForDirectories = "📁" config.DefaultIconForBack = "«" config.ThemeCacheDisabled = false @@ -281,6 +321,7 @@ func DefaultConfigWithBasePort(basePort int) *Config { config.DefaultPolicy.ShowDiagnostics = true config.DefaultPolicy.ShowLogList = true + config.DefaultPolicy.ShowVersionNumber = true return &config } diff --git a/service/internal/config/constants.go b/service/internal/config/constants.go new file mode 100644 index 0000000..bff42c3 --- /dev/null +++ b/service/internal/config/constants.go @@ -0,0 +1,3 @@ +package config + +const ContentSecurityPolicyDefault = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'" diff --git a/service/internal/config/sanitize.go b/service/internal/config/sanitize.go index 43364f5..fa588fc 100644 --- a/service/internal/config/sanitize.go +++ b/service/internal/config/sanitize.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "strings" "text/template" @@ -15,7 +16,9 @@ func (cfg *Config) Sanitize() { cfg.sanitizeLogLevel() cfg.sanitizeAuthRequireGuestsToLogin() cfg.sanitizeLogHistoryPageSize() - cfg.sanitizeLocalUserPasswords() + cfg.sanitizeLocalUsers() + cfg.sanitizeSecurityHeaders() + cfg.sanitizeOnClickDefaults() // log.Infof("cfg %p", cfg) @@ -24,6 +27,37 @@ func (cfg *Config) Sanitize() { } cfg.sanitizeDashboardsForInlineActions() + + cfg.sanitizeActionGroups() + cfg.sanitizeActionGroupReferences() + + if err := cfg.validateReservedActionArgumentNames(); err != nil { + log.Fatalf("%v", err) + } +} + +func (cfg *Config) validateReservedActionArgumentNames() error { + for _, action := range cfg.Actions { + if err := action.validateReservedArgumentNames(); err != nil { + return err + } + } + + return nil +} + +func (action *Action) validateReservedArgumentNames() error { + if action == nil { + return nil + } + + for _, arg := range action.Arguments { + if strings.HasPrefix(arg.Name, ReservedArgumentNamePrefix) { + return fmt.Errorf("action %q argument %q uses reserved prefix %q", action.Title, arg.Name, ReservedArgumentNamePrefix) + } + } + + return nil } func (cfg *Config) sanitizeDashboardsForInlineActions() { @@ -145,17 +179,88 @@ func (action *Action) sanitize(cfg *Config) { action.ID = getActionID(action) action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions) - action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg) + migrateActionOnClick(action) + action.OnClick = sanitizeOnClick(action.OnClick, cfg) + action.PopupOnStart = action.OnClick if action.MaxConcurrent < 1 { action.MaxConcurrent = 1 } + action.Groups = dedupeStrings(action.Groups) + for idx := range action.Arguments { action.Arguments[idx].sanitize() } } +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + + for _, value := range values { + out = appendUniqueString(out, seen, value) + } + + return out +} + +func appendUniqueString(out []string, seen map[string]struct{}, value string) []string { + if value == "" { + return out + } + + if _, found := seen[value]; found { + return out + } + + seen[value] = struct{}{} + + return append(out, value) +} + +const defaultActionGroupQueueSize = 5 + +func (cfg *Config) sanitizeActionGroups() { + for _, group := range cfg.ActionGroups { + if group == nil { + continue + } + + if group.QueueSize <= 0 { + group.QueueSize = defaultActionGroupQueueSize + } + + group.Icon = lookupHTMLIcon(group.Icon, cfg.DefaultIconForActions) + } +} + +func (cfg *Config) sanitizeActionGroupReferences() { + for _, action := range cfg.Actions { + for _, groupName := range action.Groups { + cfg.warnInvalidActionGroupReference(action, groupName) + } + } +} + +func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) { + group, found := cfg.ActionGroups[groupName] + if !found { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "groupName": groupName, + }).Warn("Action references unknown action group") + return + } + + if group == nil || group.MaxConcurrent < 1 { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "groupName": groupName, + }).Warn("Action references action group that will not be enforced at runtime") + } +} + func (cfg *Config) sanitizeAuthRequireGuestsToLogin() { if cfg.AuthRequireGuestsToLogin { log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false") @@ -163,6 +268,7 @@ func (cfg *Config) sanitizeAuthRequireGuestsToLogin() { cfg.DefaultPermissions.View = false cfg.DefaultPermissions.Exec = false cfg.DefaultPermissions.Logs = false + cfg.DefaultPermissions.Kill = false } } @@ -175,24 +281,86 @@ func (cfg *Config) sanitizeLogHistoryPageSize() { } } -func (cfg *Config) sanitizeLocalUserPasswords() { +func (cfg *Config) sanitizeLocalUsers() { for _, user := range cfg.AuthLocalUsers.Users { - if user.Password != "" { - user.Password = parsePasswordTemplate(user.Password) - } + expandLocalUserEnvTemplates(user) + } + + if err := validateUniqueLocalUserAPIKeys(cfg.AuthLocalUsers.Users); err != nil { + log.Fatalf("%v", err) } } -// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment. -func parsePasswordTemplate(source string) string { - t, err := template.New("password").Option("missingkey=error").Parse(source) +func expandLocalUserEnvTemplates(user *LocalUser) { + if user == nil { + return + } + + if user.Password != "" { + user.Password = expandEnvTemplate(user.Password) + } + + if user.ApiKey != "" { + user.ApiKey = expandEnvTemplate(user.ApiKey) + } +} + +// validateUniqueLocalUserAPIKeys returns an error when two local users share the same non-empty apiKey. +func validateUniqueLocalUserAPIKeys(users []*LocalUser) error { + seen := make(map[string]string) + + for _, user := range users { + if err := recordUniqueLocalUserAPIKey(seen, user); err != nil { + return err + } + } + + return nil +} + +func recordUniqueLocalUserAPIKey(seen map[string]string, user *LocalUser) error { + if user == nil || user.ApiKey == "" { + return nil + } + + if prior, ok := seen[user.ApiKey]; ok { + return fmt.Errorf("duplicate authLocalUsers apiKey for users %q and %q", prior, user.Username) + } + + seen[user.ApiKey] = user.Username + + return nil +} + +func (cfg *Config) sanitizeSecurityHeaders() { + cfg.sanitizeSecurityHeadersCSP() + cfg.sanitizeSecurityHeadersXFrameOptions() +} + +func (cfg *Config) sanitizeSecurityHeadersCSP() { + if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" { + return + } + cfg.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault +} + +func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() { + if !cfg.Security.HeaderXFrameOptions || cfg.Security.XFrameOptions != "" { + return + } + cfg.Security.XFrameOptions = "DENY" +} + +// expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment. +func expandEnvTemplate(source string) string { + t, err := template.New("envTemplate").Option("missingkey=error").Parse(source) if err != nil { - log.WithFields(log.Fields{"error": err}).Debug("Password template parse failed, using literal") + log.WithFields(log.Fields{"error": err}).Debug("Env template parse failed, using literal") return source } var b strings.Builder if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil { - log.WithFields(log.Fields{"error": err}).Debug("Password template execute failed, using literal") + log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal") return source } return b.String() @@ -211,7 +379,7 @@ func getActionID(action *Action) string { } //gocyclo:ignore -func sanitizePopupOnStart(raw string, cfg *Config) string { +func sanitizeOnClick(raw string, cfg *Config) string { switch raw { case "execution-dialog": return raw @@ -221,11 +389,46 @@ func sanitizePopupOnStart(raw string, cfg *Config) string { return raw case "execution-button": return raw + case "history": + return raw default: - return cfg.DefaultPopupOnStart + return cfg.DefaultOnClick } } +func migrateActionOnClick(action *Action) { + if action.OnClick == "" && action.PopupOnStart != "" { + action.OnClick = action.PopupOnStart + } +} + +func shouldMigrateDefaultOnClickFromPopup(onClick, popupOnStart string) bool { + if popupOnStart == "" { + return false + } + if onClick == "" { + return true + } + return onClick == "nothing" && popupOnStart != "nothing" +} + +func (cfg *Config) migrateDefaultOnClickFromLegacyPopup() { + if !shouldMigrateDefaultOnClickFromPopup(cfg.DefaultOnClick, cfg.DefaultPopupOnStart) { + return + } + cfg.DefaultOnClick = cfg.DefaultPopupOnStart +} + +func (cfg *Config) sanitizeOnClickDefaults() { + cfg.migrateDefaultOnClickFromLegacyPopup() + + if cfg.DefaultOnClick == "" { + cfg.DefaultOnClick = "nothing" + } + + cfg.DefaultPopupOnStart = cfg.DefaultOnClick +} + func (arg *ActionArgument) sanitize() { if arg.Title == "" { arg.Title = arg.Name @@ -239,8 +442,7 @@ func (arg *ActionArgument) sanitize() { arg.sanitizeNoType() - // TODO Validate the default against the type checker, but this creates a - // import loop + // Default value validation runs in executor at config load (validateArgumentDefaults). } func (arg *ActionArgument) sanitizeNoType() { diff --git a/service/internal/config/sanitize_test.go b/service/internal/config/sanitize_test.go index 85298b9..6c3d4ad 100644 --- a/service/internal/config/sanitize_test.go +++ b/service/internal/config/sanitize_test.go @@ -1,8 +1,10 @@ package config import ( - "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSanitizeConfig(t *testing.T) { @@ -32,11 +34,88 @@ func TestSanitizeConfig(t *testing.T) { assert.NotNil(t, a2, "Found action after adding it") assert.Equal(t, 3, a2.Timeout, "Default timeout is set") - assert.Equal(t, "😀", a2.Icon, "Default icon is a smiley") + assert.Equal(t, "hugeicons:CommandLineIcon", a2.Icon, "Default icon is the neutral CLI glyph") assert.Equal(t, "Carrots", a2.Arguments[0].Title, "Arg title is set to name") assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name") } +func TestSanitizePopupOnStartHistory(t *testing.T) { + c := DefaultConfig() + c.DefaultPopupOnStart = "nothing" + + c.Actions = append(c.Actions, &Action{ + Title: "With history", + PopupOnStart: "history", + Shell: "true", + }) + c.Sanitize() + + a := c.findAction("With history") + if assert.NotNil(t, a) { + assert.Equal(t, "history", a.OnClick, "history must be preserved on onclick") + assert.Equal(t, "history", a.PopupOnStart, "legacy popupOnStart must mirror onclick") + } +} + +func TestSanitizeMigratesPopupOnStartToOnClick(t *testing.T) { + c := DefaultConfig() + c.Actions = append(c.Actions, &Action{ + Title: "Legacy popup", + PopupOnStart: "execution-dialog", + Shell: "true", + }) + c.Sanitize() + + a := c.findAction("Legacy popup") + require.NotNil(t, a) + assert.Equal(t, "execution-dialog", a.OnClick) + assert.Equal(t, "execution-dialog", a.PopupOnStart) +} + +func TestSanitizeOnClickPreferredOverPopupOnStart(t *testing.T) { + c := DefaultConfig() + c.Actions = append(c.Actions, &Action{ + Title: "Preferred onclick", + OnClick: "history", + PopupOnStart: "execution-dialog", + Shell: "true", + }) + c.Sanitize() + + a := c.findAction("Preferred onclick") + require.NotNil(t, a) + assert.Equal(t, "history", a.OnClick) + assert.Equal(t, "history", a.PopupOnStart) +} + +func TestSanitizeMigratesDefaultPopupOnStartToDefaultOnClick(t *testing.T) { + c := DefaultConfig() + c.DefaultPopupOnStart = "execution-dialog" + c.DefaultOnClick = "" + c.Sanitize() + + assert.Equal(t, "execution-dialog", c.DefaultOnClick) + assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart) +} + +func TestSanitizeMigratesDefaultPopupOnStartWhenDefaultOnClickUnchanged(t *testing.T) { + c := DefaultConfig() + c.DefaultPopupOnStart = "execution-dialog" + c.Actions = append(c.Actions, &Action{ + Title: "Uses default onclick", + Shell: "true", + }) + c.Sanitize() + + assert.Equal(t, "execution-dialog", c.DefaultOnClick) + assert.Equal(t, "execution-dialog", c.DefaultPopupOnStart) + + a := c.findAction("Uses default onclick") + require.NotNil(t, a) + assert.Equal(t, "execution-dialog", a.OnClick) + assert.Equal(t, "execution-dialog", a.PopupOnStart) +} + func TestSanitizeConfigInlineDashboardActions(t *testing.T) { c := DefaultConfig() @@ -72,3 +151,123 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) { assert.NotEmpty(t, found.ID, "Inline action should have a generated ID") } } + +func TestValidateReservedActionArgumentNames(t *testing.T) { + c := DefaultConfig() + c.Actions = append(c.Actions, &Action{ + Title: "Reserved arg", + Arguments: []ActionArgument{ + {Name: "ot_custom", Type: "ascii"}, + }, + }) + + err := c.validateReservedActionArgumentNames() + + require.Error(t, err) + assert.Contains(t, err.Error(), `action "Reserved arg" argument "ot_custom" uses reserved prefix "ot_"`) +} + +func TestSanitizeActionGroupsDedupesGroupNames(t *testing.T) { + c := DefaultConfig() + c.ActionGroups = map[string]*ActionGroup{ + "unity": {MaxConcurrent: 1}, + } + c.Actions = append(c.Actions, &Action{ + Title: "Build", + Shell: "true", + Groups: []string{"unity", "unity", ""}, + }) + + c.Sanitize() + + action := c.findAction("Build") + require.NotNil(t, action) + assert.Equal(t, []string{"unity"}, action.Groups) +} + +func TestSanitizeActionGroupsResolvesIcons(t *testing.T) { + c := DefaultConfig() + c.ActionGroups = map[string]*ActionGroup{ + "backup-jobs": {MaxConcurrent: 1, Icon: "backup"}, + } + + c.Sanitize() + + assert.Equal(t, "💾", c.ActionGroups["backup-jobs"].Icon) +} + +func TestSanitizeActionGroupsDefaultsQueueSize(t *testing.T) { + c := DefaultConfig() + c.ActionGroups = map[string]*ActionGroup{ + "unity": {MaxConcurrent: 1}, + } + + c.Sanitize() + + assert.Equal(t, defaultActionGroupQueueSize, c.ActionGroups["unity"].QueueSize) +} + +func TestSanitizeActionGroupsPreservesExplicitQueueSize(t *testing.T) { + c := DefaultConfig() + c.ActionGroups = map[string]*ActionGroup{ + "unity": {MaxConcurrent: 1, QueueSize: 2}, + } + + c.Sanitize() + + assert.Equal(t, 2, c.ActionGroups["unity"].QueueSize) +} + +func TestValidateReservedActionArgumentNamesAllowsNonReserved(t *testing.T) { + c := DefaultConfig() + c.Actions = append(c.Actions, &Action{ + Title: "Allowed arg", + Arguments: []ActionArgument{ + {Name: "target", Type: "ascii"}, + }, + }) + + require.NoError(t, c.validateReservedActionArgumentNames()) +} + +func TestValidateReservedActionArgumentNamesChecksInlineActions(t *testing.T) { + c := DefaultConfig() + c.Dashboards = []*DashboardComponent{ + { + Title: "Dashboard", + Contents: []*DashboardComponent{ + { + Title: "Inline reserved arg", + InlineAction: &Action{ + Shell: "echo test", + Arguments: []ActionArgument{ + {Name: "ot_custom", Type: "ascii"}, + }, + }, + }, + }, + }, + } + + c.sanitizeDashboardsForInlineActions() + err := c.validateReservedActionArgumentNames() + + require.Error(t, err) + assert.Contains(t, err.Error(), `action "Inline reserved arg" argument "ot_custom" uses reserved prefix "ot_"`) +} + +func TestValidateUniqueLocalUserAPIKeys(t *testing.T) { + t.Parallel() + + err := validateUniqueLocalUserAPIKeys([]*LocalUser{ + {Username: "a", ApiKey: "same"}, + {Username: "b", ApiKey: "same"}, + }) + require.Error(t, err) + + err = validateUniqueLocalUserAPIKeys([]*LocalUser{ + {Username: "a", ApiKey: "one"}, + {Username: "b", ApiKey: "two"}, + }) + require.NoError(t, err) +} diff --git a/service/internal/cors/cors.go b/service/internal/cors/cors.go deleted file mode 100644 index 905ed46..0000000 --- a/service/internal/cors/cors.go +++ /dev/null @@ -1,23 +0,0 @@ -package cors - -import ( - log "github.com/sirupsen/logrus" - "net/http" -) - -// AllowCors takes a HTTP handler and adds Access-Control-Allow-Origin headers to -// responses. -// -// Note: HTTP OPTIONS requests (which need to be preflighted" for CORS) are not -// handled because this app does not use HTTP PUT/PATCH/etc. -func AllowCors(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if origin := r.Header.Get("Origin"); origin != "" { - log.Debugf("Adding CORS header origin: %q", origin) - - w.Header().Set("Access-Control-Allow-Origin", origin) - } - - h.ServeHTTP(w, r) - }) -} diff --git a/service/internal/cors/cors_test.go b/service/internal/cors/cors_test.go deleted file mode 100644 index bfd87e2..0000000 --- a/service/internal/cors/cors_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package cors - -import ( - "github.com/stretchr/testify/assert" - "net/http" - "net/http/httptest" - "testing" -) - -func TestCors(t *testing.T) { - req, _ := http.NewRequest("GET", "/health-check", nil) - req.Header.Add("Origin", "1.2.3.4") - - blat := AllowCors(http.FileServer(http.Dir("."))) - - rr := httptest.NewRecorder() - - blat.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusNotFound, rr.Code, "HTTP 404 on CORS") - assert.Equal(t, "1.2.3.4", rr.Header().Get("Access-Control-Allow-Origin"), "CORS Header set") -} diff --git a/service/internal/entities/entities_test.go b/service/internal/entities/entities_test.go index ab135d3..b13fd08 100644 --- a/service/internal/entities/entities_test.go +++ b/service/internal/entities/entities_test.go @@ -1,8 +1,10 @@ package entities import ( - // "github.com/stretchr/testify/assert" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestLoadObjectPerLineJsonFile(t *testing.T) { @@ -16,3 +18,44 @@ func TestLoadObjectPerLineJsonFile(t *testing.T) { assert.Equal(t, "1234567890", GetEntity("testrow", "0"), "Value should match expected value") */ } + +func TestGetEntityInstancesOrdered_numericKeys(t *testing.T) { + ClearEntitiesOfType("order_test") + defer ClearEntitiesOfType("order_test") + + AddEntity("order_test", "2", map[string]any{"title": "Second"}) + AddEntity("order_test", "0", map[string]any{"title": "Zeroth"}) + AddEntity("order_test", "10", map[string]any{"title": "Tenth"}) + AddEntity("order_test", "1", map[string]any{"title": "First"}) + + ordered := GetEntityInstancesOrdered("order_test") + require.Len(t, ordered, 4, "should return 4 entities") + assert.Equal(t, "0", ordered[0].UniqueKey, "first key should be 0") + assert.Equal(t, "1", ordered[1].UniqueKey, "second key should be 1") + assert.Equal(t, "2", ordered[2].UniqueKey, "third key should be 2") + assert.Equal(t, "10", ordered[3].UniqueKey, "fourth key should be 10 (numeric order)") +} + +func TestGetEntityInstancesOrdered_lexicographicKeys(t *testing.T) { + ClearEntitiesOfType("order_test_lex") + defer ClearEntitiesOfType("order_test_lex") + + AddEntity("order_test_lex", "zebra", map[string]any{"title": "Z"}) + AddEntity("order_test_lex", "alpha", map[string]any{"title": "A"}) + AddEntity("order_test_lex", "beta", map[string]any{"title": "B"}) + + ordered := GetEntityInstancesOrdered("order_test_lex") + require.Len(t, ordered, 3, "should return 3 entities") + assert.Equal(t, "alpha", ordered[0].UniqueKey) + assert.Equal(t, "beta", ordered[1].UniqueKey) + assert.Equal(t, "zebra", ordered[2].UniqueKey) +} + +func TestGetEntityInstancesOrdered_emptyOrMissing(t *testing.T) { + ordered := GetEntityInstancesOrdered("nonexistent_type") + assert.Nil(t, ordered) + + ClearEntitiesOfType("empty_test") + ordered = GetEntityInstancesOrdered("empty_test") + assert.Nil(t, ordered) +} diff --git a/service/internal/entities/storage.go b/service/internal/entities/storage.go index e1fac78..bd61718 100644 --- a/service/internal/entities/storage.go +++ b/service/internal/entities/storage.go @@ -10,6 +10,8 @@ package entities */ import ( + "sort" + "strconv" "strings" "sync" ) @@ -64,6 +66,49 @@ func GetEntityInstances(entityName string) entityInstancesByKey { return make(entityInstancesByKey, 0) } +func GetEntityInstancesOrdered(entityName string) []*Entity { + instances := GetEntityInstances(entityName) + if len(instances) == 0 { + return nil + } + + keys := make([]string, 0, len(instances)) + for key := range instances { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + return compareEntityKeys(keys[i], keys[j]) < 0 + }) + + result := make([]*Entity, 0, len(keys)) + for _, key := range keys { + result = append(result, instances[key]) + } + return result +} + +//gocyclo:ignore +func compareEntityKeys(a, b string) int { + ai, errA := strconv.ParseInt(a, 10, 64) + bi, errB := strconv.ParseInt(b, 10, 64) + if errA == nil && errB == nil { + if ai < bi { + return -1 + } + if ai > bi { + return 1 + } + return 0 + } + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} + func AddEntity(entityName string, entityKey string, data any) { rwmutex.Lock() diff --git a/service/internal/executor/arguments.go b/service/internal/executor/arguments.go index a63eb0b..b16667e 100644 --- a/service/internal/executor/arguments.go +++ b/service/internal/executor/arguments.go @@ -21,6 +21,7 @@ var ( "unicode_identifier": `^[\w\-\.\_\d]+$`, "ascii": `^[a-zA-Z0-9]+$`, "ascii_identifier": `^[a-zA-Z0-9\-\._]+$`, + "shell_safe_identifier": `^[a-zA-Z0-9@\.\_\+\-]+$`, "ascii_sentence": `^[a-zA-Z0-9\-\._, ]+$`, } ) @@ -145,8 +146,17 @@ func redactExecArgs(execArgs []string, arguments []config.ActionArgument, argume return redacted } +func argumentSkipsValidation(arg *config.ActionArgument) bool { + switch arg.Type { + case "confirmation", "html": + return true + default: + return false + } +} + func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error { - if arg.Type == "confirmation" { + if argumentSkipsValidation(arg) { return nil } @@ -250,13 +260,10 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error { func typeSafetyCheckEmail(value string) error { _, err := mail.ParseAddress(value) - - log.Errorf("Email check: %v, %v", err, value) - if err != nil { + log.WithField("type", "email").Debugf("Email argument type check failed") return err } - return nil } @@ -310,7 +317,7 @@ func checkShellArgumentSafety(action *config.Action) error { if action.Shell == "" { return nil } - unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}} + unsafe := map[string]struct{}{"url": {}, "email": {}, "raw_string_multiline": {}, "very_dangerous_raw_string": {}, "password": {}} for _, arg := range action.Arguments { if _, bad := unsafe[arg.Type]; bad { return fmt.Errorf("unsafe argument type '%s' cannot be used with Shell execution. Use 'exec' instead. See https://docs.olivetin.app/action_execution/shellvsexec.html", arg.Type) diff --git a/service/internal/executor/arguments_test.go b/service/internal/executor/arguments_test.go index 877b10c..69b3e43 100644 --- a/service/internal/executor/arguments_test.go +++ b/service/internal/executor/arguments_test.go @@ -302,6 +302,40 @@ func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) { assert.Nil(t, err) } +func TestCheckShellArgumentSafetyWithPassword(t *testing.T) { + a1 := config.Action{ + Title: "Auth with password", + Shell: "somecommand --password '{{password}}'", + Arguments: []config.ActionArgument{ + { + Name: "password", + Type: "password", + }, + }, + } + + err := checkShellArgumentSafety(&a1) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "unsafe argument type 'password' cannot be used with Shell execution") + assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html") +} + +func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) { + a1 := config.Action{ + Title: "Auth with password via exec", + Exec: []string{"somecommand", "--password", "{{password}}"}, + Arguments: []config.ActionArgument{ + { + Name: "password", + Type: "password", + }, + }, + } + + err := checkShellArgumentSafety(&a1) + assert.Nil(t, err) +} + func TestTypeSafetyCheckUrl(t *testing.T) { assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com") assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments") @@ -542,6 +576,38 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) { } } +func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) { + tests := []struct { + name string + value string + hasError bool + }{ + {"Simple username", "alice123", false}, + {"Email username", "alice@example.com", false}, + {"Plus addressing", "alice+test@example.com", false}, + {"Hyphen underscore dot", "alice-test_user.example", false}, + {"Invalid space", "alice example", true}, + {"Invalid shell substitution", "$(whoami)", true}, + {"Invalid backtick", "`whoami`", true}, + {"Invalid semicolon", "alice;id", true}, + {"Invalid ampersand", "alice&id", true}, + {"Invalid pipe", "alice|id", true}, + {"Invalid quote", "alice'example", true}, + {"Invalid slash", "alice/example", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier") + if tt.hasError { + assert.NotNil(t, err, "Expected error for value '%s'", tt.value) + } else { + assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) + } + }) + } +} + func TestTypeSafetyCheckAsciiSentence(t *testing.T) { tests := []struct { name string @@ -594,6 +660,20 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) { assert.Nil(t, err, "Confirmation type should always pass validation") } +func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) { + action := config.Action{ + Title: "Delete old backups", + Shell: "rm -rf /opt/oliveTinOldBackups/ && sleep 5", + Arguments: []config.ActionArgument{ + {Type: "html", Title: "Description"}, + {Type: "confirmation", Title: "Are you sure?!"}, + }, + } + + err := validateArguments(map[string]string{}, &action) + assert.NoError(t, err) +} + func TestParseCommandForReplacements(t *testing.T) { tests := []struct { name string diff --git a/service/internal/executor/executor.go b/service/internal/executor/executor.go index 274d4b1..e181a8b 100644 --- a/service/internal/executor/executor.go +++ b/service/internal/executor/executor.go @@ -6,6 +6,7 @@ import ( authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" + "github.com/OliveTin/OliveTin/internal/logfilter" "github.com/OliveTin/OliveTin/internal/tpl" "github.com/google/uuid" log "github.com/sirupsen/logrus" @@ -20,6 +21,7 @@ import ( "os" "os/exec" "path" + "regexp" "strings" "sync" "time" @@ -30,6 +32,14 @@ const ( MaxTriggerDepth = 10 ) +var validTrackingIDPattern = regexp.MustCompile(`^[a-fA-F0-9\-]+$`) + +func isValidTrackingID(id string) bool { + const MaxTrackingIDLength = 36 + + return id != "" && len(id) <= MaxTrackingIDLength && validTrackingIDPattern.MatchString(id) +} + var ( metricActionsRequested = promauto.NewCounter(prometheus.CounterOpts{ Name: "olivetin_actions_requested_count", @@ -38,11 +48,11 @@ var ( ) type ActionBinding struct { - ID string - Action *config.Action - Entity *entities.Entity - ConfigOrder int - IsOnDashboard bool + ID string + Action *config.Action + Entity *entities.Entity + ConfigOrder int + OnDashboards []DashboardNavigationTarget } // Executor represents a helper class for executing commands. It's main method @@ -59,9 +69,13 @@ type Executor struct { Cfg *config.Config - listeners []listener + listeners []listener + listenersMu sync.RWMutex chainOfCommand []executorStepFunc + + groupQueue []*queuedExecution + groupQueueMu sync.Mutex } // ExecutionRequest is a request to execute an action. It's passed to an @@ -74,12 +88,56 @@ type ExecutionRequest struct { Cfg *config.Config AuthenticatedUser *authpublic.AuthenticatedUser TriggerDepth int + Justification string - logEntry *InternalLogEntry - finalParsedCommand string - execArgs []string - useDirectExec bool - executor *Executor + logEntry *InternalLogEntry + finalParsedCommand string + execArgs []string + useDirectExec bool + executor *Executor + skipRequestRegistration bool +} + +func (req *ExecutionRequest) mutateLogEntry(mutator func(*InternalLogEntry)) { + if req.executor == nil { + mutator(req.logEntry) + return + } + + req.executor.logmutex.Lock() + defer req.executor.logmutex.Unlock() + + mutator(req.logEntry) +} + +// LogEntrySnapshot is a copy of selected log entry fields for race-safe reads. +type LogEntrySnapshot struct { + Queued bool + Blocked bool + ExecutionStarted bool + ExecutionFinished bool + ExitCode int32 + Output string +} + +// SnapshotLog returns a copy of selected log entry fields under read lock. +func (e *Executor) SnapshotLog(trackingID string) (LogEntrySnapshot, bool) { + e.logmutex.RLock() + defer e.logmutex.RUnlock() + + entry, found := e.logs[trackingID] + if !found { + return LogEntrySnapshot{}, false + } + + return LogEntrySnapshot{ + Queued: entry.Queued, + Blocked: entry.Blocked, + ExecutionStarted: entry.ExecutionStarted, + ExecutionFinished: entry.ExecutionFinished, + ExitCode: entry.ExitCode, + Output: entry.Output, + }, true } // InternalLogEntry objects are created by an Executor, and represent the final @@ -92,6 +150,8 @@ type InternalLogEntry struct { Output string TimedOut bool Blocked bool + Queued bool + QueuedForGroup string ExitCode int32 Tags []string ExecutionStarted bool @@ -108,8 +168,9 @@ type InternalLogEntry struct { that logs are lightweight (so we don't need to have an action associated to logs, etc. Therefore, we duplicate those values here. */ - ActionTitle string - ActionIcon string + ActionTitle string + ActionIcon string + Justification string } // .Binding can be nil, so we need to handle that. @@ -158,9 +219,19 @@ type listener interface { } func (e *Executor) AddListener(m listener) { + e.listenersMu.Lock() + defer e.listenersMu.Unlock() e.listeners = append(e.listeners, m) } +func (e *Executor) copyListeners() []listener { + e.listenersMu.RLock() + defer e.listenersMu.RUnlock() + out := make([]listener, len(e.listeners)) + copy(out, e.listeners) + return out +} + // getPagingStartIndex calculates the starting index for log pagination. // Parameters: // @@ -222,7 +293,7 @@ func (e *Executor) GetLogTrackingIds(startOffset int64, pageCount int64) ([]*Int trackingIds := make([]*InternalLogEntry, 0, pageCount) if totalLogCount > 0 { - for i := endIndex; i <= startIndex; i++ { + for i := startIndex; i >= endIndex; i-- { trackingIds = append(trackingIds, e.logs[e.logsTrackingIdsByDate[i]]) } } @@ -318,7 +389,7 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC endIndex := max(0, (startIndex-pageCount)+1) out := make([]*InternalLogEntry, 0, pageCount) - for i := endIndex; i <= startIndex && i < int64(len(filtered)); i++ { + for i := startIndex; i >= endIndex && i < int64(len(filtered)); i-- { out = append(out, filtered[i]) } @@ -329,9 +400,22 @@ func paginateFilteredLogs(filtered []*InternalLogEntry, startOffset int64, pageC // GetLogTrackingIdsACL returns logs filtered by ACL visibility for the user and // paginated correctly based on the filtered set. // dateFilter is optional and should be in YYYY-MM-DD format. If empty, no date filtering is applied. -func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64, dateFilter string) ([]*InternalLogEntry, *PagingResult) { +// expressionFilter is an optional filter expression applied after ACL checks. +func (e *Executor) GetLogTrackingIdsACL(cfg *config.Config, user *authpublic.AuthenticatedUser, startOffset int64, pageCount int64, dateFilter string, expressionFilter string) ([]*InternalLogEntry, *PagingResult, error) { filtered := e.filterLogsByACL(cfg, user, dateFilter) - return paginateFilteredLogs(filtered, startOffset, pageCount) + + program, err := logfilter.Compile(expressionFilter) + if err != nil { + return nil, nil, err + } + + filtered, err = applyLogFilter(filtered, program) + if err != nil { + return nil, nil, err + } + + logs, paging := paginateFilteredLogs(filtered, startOffset, pageCount) + return logs, paging, nil } func (e *Executor) GetLog(trackingID string) (*InternalLogEntry, bool) { @@ -360,7 +444,7 @@ func (e *Executor) GetLogsByBindingId(bindingId string) []*InternalLogEntry { // shouldCountExecution checks if a log entry should be counted for rate limiting. func shouldCountExecution(logEntry *InternalLogEntry, windowStart time.Time) bool { - return !logEntry.Blocked && logEntry.DatetimeStarted.After(windowStart) + return !logEntry.Blocked && !logEntry.Queued && logEntry.DatetimeStarted.After(windowStart) } // updateOldestExecution updates the oldest execution time if this entry is older. @@ -474,19 +558,45 @@ func (e *Executor) GetTimeUntilAvailable(binding *ActionBinding) int64 { return maxExpiryTime.Unix() } -func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) { +func (e *Executor) SetLog(trackingID string, entry *InternalLogEntry) string { e.logmutex.Lock() + defer e.logmutex.Unlock() + + if _, found := e.logs[trackingID]; found || !isValidTrackingID(trackingID) { + trackingID = uuid.NewString() + entry.ExecutionTrackingID = trackingID + } entry.Index = int64(len(e.logsTrackingIdsByDate)) e.logs[trackingID] = entry e.logsTrackingIdsByDate = append(e.logsTrackingIdsByDate, trackingID) - e.logmutex.Unlock() + return trackingID } // ExecRequest processes an ExecutionRequest func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) { + e.initializeExecRequest(req) + + log.Tracef("executor.ExecRequest(): trackingID=%s bindingID=%s", req.TrackingID, bindingIDForTrace(req)) + + req.TrackingID = e.SetLog(req.TrackingID, req.logEntry) + + wg := new(sync.WaitGroup) + wg.Add(1) + + go func() { + queued := e.execChain(req, wg) + if !queued { + wg.Done() + } + }() + + return wg, req.TrackingID +} + +func (e *Executor) initializeExecRequest(req *ExecutionRequest) { if req.AuthenticatedUser == nil { req.AuthenticatedUser = auth.UserGuest(req.Cfg) } @@ -504,57 +614,109 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string) ActionIcon: "💩", Username: req.AuthenticatedUser.Username, } - - _, isDuplicate := e.GetLog(req.TrackingID) - - if isDuplicate || req.TrackingID == "" { - req.TrackingID = uuid.NewString() - } - - // Update the log entry with the final tracking ID - req.logEntry.ExecutionTrackingID = req.TrackingID - - log.Tracef("executor.ExecRequest(): %v", req) - - e.SetLog(req.TrackingID, req.logEntry) - - wg := new(sync.WaitGroup) - wg.Add(1) - - go func() { - e.execChain(req) - defer wg.Done() - }() - - return wg, req.TrackingID } -func (e *Executor) execChain(req *ExecutionRequest) { - for _, step := range e.chainOfCommand { +func bindingIDForTrace(req *ExecutionRequest) string { + if req.Binding == nil { + return "" + } + + return req.Binding.ID +} + +func (e *Executor) execChain(req *ExecutionRequest, wg *sync.WaitGroup) bool { + if !req.skipRequestRegistration { + finished, queued := e.registerOrQueueRequest(req, wg) + if finished || queued { + return queued + } + } + + e.runExecutionSteps(req) + e.finishExecChain(req) + + return false +} + +func (e *Executor) registerOrQueueRequest(req *ExecutionRequest, wg *sync.WaitGroup) (finished bool, queued bool) { + if !stepRequestAction(req) { + e.finishExecChain(req) + return true, false + } + + if e.finishIfConcurrencyBlocked(req) { + return true, false + } + + return e.queueRequestIfGroupLimited(req, wg) +} + +func (e *Executor) finishIfConcurrencyBlocked(req *ExecutionRequest) bool { + if actionNeedsGroupLimit(req) { + return false + } + + if stepConcurrencyCheck(req) { + return false + } + + e.finishExecChain(req) + return true +} + +func (e *Executor) queueRequestIfGroupLimited(req *ExecutionRequest, wg *sync.WaitGroup) (finished bool, queued bool) { + if !actionNeedsGroupLimit(req) || e.groupsHaveCapacityForActive(req) { + return false, false + } + + return e.queueRequestAfterACL(req, wg) +} + +func (e *Executor) queueRequestAfterACL(req *ExecutionRequest, wg *sync.WaitGroup) (finished bool, queued bool) { + if !stepACLCheck(req) { + e.finishExecChain(req) + return true, false + } + + if e.queueRequest(req, wg) { + e.finishExecChain(req) + return true, false + } + + notifyListenersStarted(req) + + return false, true +} + +func (e *Executor) runExecutionSteps(req *ExecutionRequest) { + for _, step := range e.chainOfCommand[1:] { if !step(req) { break } } +} - // Ensure DatetimeFinished is set even if execution was blocked early - if req.logEntry.DatetimeFinished.IsZero() { - req.logEntry.DatetimeFinished = time.Now() - } +func (e *Executor) finishExecChain(req *ExecutionRequest) { + req.mutateLogEntry(func(entry *InternalLogEntry) { + if entry.DatetimeFinished.IsZero() { + entry.DatetimeFinished = time.Now() + } - req.logEntry.ExecutionFinished = true + entry.ExecutionFinished = true + }) - // This isn't a step, because we want to notify all listeners, irrespective - // of how many steps were actually executed. notifyListenersFinished(req) + e.drainGroupQueue() } func getConcurrentCount(req *ExecutionRequest) int { concurrentCount := 0 req.executor.logmutex.RLock() + logs := req.executor.LogsByBindingId[req.Binding.ID] - for _, log := range req.executor.GetLogsByBindingId(req.Binding.ID) { - if !log.ExecutionFinished { + for _, logEntry := range logs { + if !logEntry.ExecutionFinished && !logEntry.Queued { concurrentCount += 1 } } @@ -565,6 +727,10 @@ func getConcurrentCount(req *ExecutionRequest) int { } func stepConcurrencyCheck(req *ExecutionRequest) bool { + if actionNeedsGroupLimit(req) { + return true + } + concurrentCount := getConcurrentCount(req) // Note that the current execution is counted int the logs, so when checking we +1 @@ -575,8 +741,10 @@ func stepConcurrencyCheck(req *ExecutionRequest) bool { "maxConcurrent": req.Binding.Action.MaxConcurrent, }).Warnf("Blocked from executing due to concurrency limit") - req.logEntry.Output = "Blocked from executing due to concurrency limit" - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Blocked from executing due to concurrency limit" + entry.Blocked = true + }) return false } @@ -595,24 +763,35 @@ func parseDuration(rate config.RateSpec) time.Duration { return duration } -//gocyclo:ignore -func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { - executions := -1 // Because we will find ourself when checking execution logs +func entityPrefixForRequest(req *ExecutionRequest) string { + if req.Binding != nil && req.Binding.Entity != nil { + return req.Binding.Entity.UniqueKey + } - duration := parseDuration(rate) + return "" +} - then := time.Now().Add(-duration) +func rateExecutionMatchesScope(logEntry *InternalLogEntry, req *ExecutionRequest, entityPrefix string) bool { + if logEntry.EntityPrefix != entityPrefix { + return false + } - for _, logEntry := range req.executor.GetLogsByBindingId(req.Binding.ID) { - // FIXME - /* - if logEntry.EntityPrefix != req.EntityPrefix { - continue - } - */ + return !logEntry.Queued && logEntry.ExecutionTrackingID != req.TrackingID +} - if logEntry.DatetimeStarted.After(then) && !logEntry.Blocked { +func logEntryStartedInWindow(logEntry *InternalLogEntry, windowStart time.Time) bool { + return logEntry.DatetimeStarted.After(windowStart) && !logEntry.Blocked +} +func rateExecutionCountsForRate(logEntry *InternalLogEntry, req *ExecutionRequest, entityPrefix string, windowStart time.Time) bool { + return rateExecutionMatchesScope(logEntry, req, entityPrefix) && logEntryStartedInWindow(logEntry, windowStart) +} + +func countRateExecutions(logs []*InternalLogEntry, req *ExecutionRequest, entityPrefix string, windowStart time.Time) int { + executions := 0 + + for _, logEntry := range logs { + if rateExecutionCountsForRate(logEntry, req, entityPrefix, windowStart) { executions += 1 } } @@ -620,6 +799,18 @@ func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { return executions } +func getExecutionsCount(rate config.RateSpec, req *ExecutionRequest) int { + duration := parseDuration(rate) + then := time.Now().Add(-duration) + + req.executor.logmutex.RLock() + logs := req.executor.LogsByBindingId[req.Binding.ID] + executions := countRateExecutions(logs, req, entityPrefixForRequest(req), then) + req.executor.logmutex.RUnlock() + + return executions +} + func stepRateCheck(req *ExecutionRequest) bool { for _, rate := range req.Binding.Action.MaxRate { executions := getExecutionsCount(rate, req) @@ -632,8 +823,10 @@ func stepRateCheck(req *ExecutionRequest) bool { "duration": rate.Duration, }).Infof("Blocked from executing due to rate limit") - req.logEntry.Output = "Blocked from executing due to rate limit" - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Blocked from executing due to rate limit" + entry.Blocked = true + }) return false } } @@ -645,8 +838,10 @@ func stepACLCheck(req *ExecutionRequest) bool { canExec := acl.IsAllowedExec(req.Cfg, req.AuthenticatedUser, req.Binding.Action) if !canExec { - req.logEntry.Output = "ACL check failed. Blocked from executing." - req.logEntry.Blocked = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "ACL check failed. Blocked from executing." + entry.Blocked = true + }) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -658,12 +853,15 @@ func stepACLCheck(req *ExecutionRequest) bool { func stepParseArgs(req *ExecutionRequest) bool { ensureArgumentMap(req) - injectSystemArgs(req) if !hasBindingAndAction(req) { return fail(req, fmt.Errorf("cannot parse arguments: Binding or Action is nil")) } + filterToDefinedArgumentsOnly(req) + if err := injectSystemArgs(req); err != nil { + return fail(req, err) + } mangleInvalidArgumentValues(req) if hasExec(req) { @@ -686,6 +884,9 @@ func handleExecBranch(req *ExecutionRequest) bool { } func handleShellBranch(req *ExecutionRequest) bool { + if hasWebhookTag(req) { + return fail(req, fmt.Errorf("webhooks cannot use Shell execution; use exec instead. See https://docs.olivetin.app/action_execution/shellvsexec.html")) + } if err := checkShellArgumentSafety(req.Binding.Action); err != nil { return fail(req, err) } @@ -707,9 +908,66 @@ func ensureArgumentMap(req *ExecutionRequest) { } } -func injectSystemArgs(req *ExecutionRequest) { - req.Arguments["ot_executionTrackingId"] = req.TrackingID - req.Arguments["ot_username"] = req.AuthenticatedUser.Username +func filterToDefinedArgumentsOnly(req *ExecutionRequest) { + definedNames := make(map[string]struct{}) + for _, arg := range req.Binding.Action.Arguments { + definedNames[arg.Name] = struct{}{} + } + filtered := make(map[string]string) + for k, v := range req.Arguments { + if keepArgument(k, definedNames) { + filtered[k] = v + } + } + req.Arguments = filtered +} + +func keepArgument(name string, definedNames map[string]struct{}) bool { + _, ok := definedNames[name] + return ok +} + +func hasWebhookTag(req *ExecutionRequest) bool { + for _, tag := range req.Tags { + if tag == "webhook" { + return true + } + } + return false +} + +var systemArgumentDefinitions = []config.ActionArgument{ + {Name: "ot_executionTrackingId", Type: "ascii_identifier", RejectNull: true}, + {Name: "ot_username", Type: "shell_safe_identifier", RejectNull: true}, +} + +func injectSystemArgs(req *ExecutionRequest) error { + args, err := validatedSystemArgs(req) + if err != nil { + return err + } + + for name, value := range args { + req.Arguments[name] = value + } + + return nil +} + +func validatedSystemArgs(req *ExecutionRequest) (map[string]string, error) { + values := map[string]string{ + "ot_executionTrackingId": req.TrackingID, + "ot_username": req.AuthenticatedUser.Username, + } + + for i := range systemArgumentDefinitions { + arg := &systemArgumentDefinitions[i] + if err := ValidateArgument(arg, values[arg.Name], req.Binding.Action); err != nil { + return nil, fmt.Errorf("system argument %q failed validation: %w", arg.Name, err) + } + } + + return values, nil } func hasBindingAndAction(req *ExecutionRequest) bool { @@ -721,7 +979,9 @@ func hasExec(req *ExecutionRequest) bool { } func fail(req *ExecutionRequest, err error) bool { - req.logEntry.Output = err.Error() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = err.Error() + }) log.Warn(err.Error()) return false } @@ -729,28 +989,12 @@ func fail(req *ExecutionRequest, err error) bool { func stepRequestAction(req *ExecutionRequest) bool { metricActionsRequested.Inc() - // If there is no binding or action, do not proceed. Leave default - // log entry values (icon/title/id) and stop execution gracefully. - if req.Binding == nil || req.Binding.Action == nil { - log.Warnf("Action request has no binding/action; skipping execution") + if !stepRequestActionHasBinding(req) { return false } - req.logEntry.Binding = req.Binding - req.logEntry.ActionConfigTitle = req.Binding.Action.Title - req.logEntry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity) - req.logEntry.ActionIcon = req.Binding.Action.Icon - req.logEntry.Tags = req.Tags - - req.executor.logmutex.Lock() - - if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey { - req.executor.LogsByBindingId[req.Binding.ID] = make([]*InternalLogEntry, 0) - } - - req.executor.LogsByBindingId[req.Binding.ID] = append(req.executor.LogsByBindingId[req.Binding.ID], req.logEntry) - - req.executor.logmutex.Unlock() + stepRequestActionPopulateLogEntry(req) + stepRequestActionRegisterLog(req) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -762,6 +1006,38 @@ func stepRequestAction(req *ExecutionRequest) bool { return true } +func stepRequestActionHasBinding(req *ExecutionRequest) bool { + if req.Binding == nil || req.Binding.Action == nil { + log.Warnf("Action request has no binding/action; skipping execution") + return false + } + return true +} + +func stepRequestActionPopulateLogEntry(req *ExecutionRequest) { + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Binding = req.Binding + entry.ActionConfigTitle = req.Binding.Action.Title + entry.ActionTitle = tpl.ParseTemplateOfActionBeforeExec(req.Binding.Action.Title, req.Binding.Entity) + entry.ActionIcon = req.Binding.Action.Icon + entry.Tags = req.Tags + entry.Justification = ResolveJustification(req) + if req.Binding.Entity != nil { + entry.EntityPrefix = req.Binding.Entity.UniqueKey + } + }) +} + +func stepRequestActionRegisterLog(req *ExecutionRequest) { + req.executor.logmutex.Lock() + defer req.executor.logmutex.Unlock() + + if _, containsKey := req.executor.LogsByBindingId[req.Binding.ID]; !containsKey { + req.executor.LogsByBindingId[req.Binding.ID] = make([]*InternalLogEntry, 0) + } + req.executor.LogsByBindingId[req.Binding.ID] = append(req.executor.LogsByBindingId[req.Binding.ID], req.logEntry) +} + func stepLogStart(req *ExecutionRequest) bool { log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -772,7 +1048,9 @@ func stepLogStart(req *ExecutionRequest) bool { } func stepLogFinish(req *ExecutionRequest) bool { - req.logEntry.ExecutionFinished = true + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.ExecutionFinished = true + }) log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, @@ -785,21 +1063,25 @@ func stepLogFinish(req *ExecutionRequest) bool { } func notifyListenersFinished(req *ExecutionRequest) { - for _, listener := range req.executor.listeners { + for _, listener := range req.executor.copyListeners() { listener.OnExecutionFinished(req.logEntry) } } func notifyListenersStarted(req *ExecutionRequest) { - for _, listener := range req.executor.listeners { + for _, listener := range req.executor.copyListeners() { listener.OnExecutionStarted(req.logEntry) } } -func appendErrorToStderr(err error, logEntry *InternalLogEntry) { - if err != nil { - logEntry.Output = err.Error() + "\n\n" + logEntry.Output +func appendErrorToStderr(req *ExecutionRequest, err error) { + if err == nil { + return } + + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = err.Error() + "\n\n" + entry.Output + }) } type OutputStreamer struct { @@ -808,7 +1090,7 @@ type OutputStreamer struct { } func (ost *OutputStreamer) Write(o []byte) (n int, err error) { - for _, listener := range ost.Req.executor.listeners { + for _, listener := range ost.Req.executor.copyListeners() { listener.OnOutputChunk(o, ost.Req.TrackingID) } @@ -836,37 +1118,54 @@ func buildEnv(args map[string]string) []string { return ret } +func commandExitCode(cmd *exec.Cmd) int { + if cmd == nil || cmd.ProcessState == nil { + return -1 + } + return cmd.ProcessState.ExitCode() +} + func stepExec(req *ExecutionRequest) bool { ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor) defer cancel() streamer := &OutputStreamer{Req: req} cmd := buildCommand(ctx, req) if cmd == nil { - req.logEntry.Output = "Cannot execute: no command arguments provided" + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = "Cannot execute: no command arguments provided" + }) log.Warn("Cannot execute: no command arguments provided") return false } prepareCommand(cmd, streamer, req) runerr := cmd.Start() - req.logEntry.Process = cmd.Process + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Process = cmd.Process + }) ctx.setProcess(cmd.Process) waiterr := cmd.Wait() - req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode()) - req.logEntry.Output = streamer.String() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.ExitCode = int32(commandExitCode(cmd)) + entry.Output = streamer.String() + }) - appendErrorToStderr(runerr, req.logEntry) - appendErrorToStderr(waiterr, req.logEntry) + appendErrorToStderr(req, runerr) + appendErrorToStderr(req, waiterr) if ctx.Err() == context.DeadlineExceeded { log.WithFields(log.Fields{ "actionTitle": req.logEntry.ActionTitle, }).Warnf("Action timed out") - req.logEntry.TimedOut = true - req.logEntry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help." + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.TimedOut = true + entry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help." + }) } - req.logEntry.DatetimeFinished = time.Now() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.DatetimeFinished = time.Now() + }) return true } @@ -882,40 +1181,35 @@ func prepareCommand(cmd *exec.Cmd, streamer *OutputStreamer, req *ExecutionReque cmd.Stdout = streamer cmd.Stderr = streamer cmd.Env = buildEnv(req.Arguments) - req.logEntry.ExecutionStarted = true + + started := false + req.mutateLogEntry(func(entry *InternalLogEntry) { + if entry.ExecutionStarted { + return + } + entry.ExecutionStarted = true + started = true + }) + if started { + notifyListenersStarted(req) + } } func stepExecAfter(req *ExecutionRequest) bool { - if req.Binding.Action.ShellAfterCompleted == "" { - return true - } - ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor) defer cancel() var stdout bytes.Buffer var stderr bytes.Buffer - args := map[string]string{ - "output": req.logEntry.Output, - "exitCode": fmt.Sprintf("%v", req.logEntry.ExitCode), - "ot_executionTrackingId": req.TrackingID, - "ot_username": req.AuthenticatedUser.Username, - } - - finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args) - + cmd, args, err := buildShellAfterCommand(ctx, req, &stdout, &stderr) if err != nil { - msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n" - req.logEntry.Output += msg - log.Warn(msg) + return fail(req, err) + } + if cmd == nil { return true } - cmd := wrapCommandInShell(ctx, finalParsedCommand) - cmd.Stdout = &stdout - cmd.Stderr = &stderr - cmd.Env = buildEnv(args) runerr := cmd.Start() @@ -923,28 +1217,71 @@ func stepExecAfter(req *ExecutionRequest) bool { waiterr := cmd.Wait() - req.logEntry.Output += "\n" - req.logEntry.Output += "OliveTin::shellAfterCompleted stdout\n" - req.logEntry.Output += stdout.String() + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += "\n" + entry.Output += "OliveTin::shellAfterCompleted stdout\n" + entry.Output += stdout.String() + entry.Output += "OliveTin::shellAfterCompleted stderr\n" + entry.Output += stderr.String() + entry.Output += "OliveTin::shellAfterCompleted errors and summary\n" + }) - req.logEntry.Output += "OliveTin::shellAfterCompleted stderr\n" - req.logEntry.Output += stderr.String() - - req.logEntry.Output += "OliveTin::shellAfterCompleted errors and summary\n" - appendErrorToStderr(runerr, req.logEntry) - appendErrorToStderr(waiterr, req.logEntry) + appendErrorToStderr(req, runerr) + appendErrorToStderr(req, waiterr) if ctx.Err() == context.DeadlineExceeded { - req.logEntry.Output += "Your shellAfterCompleted command timed out." + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += "Your shellAfterCompleted command timed out." + }) } - req.logEntry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", cmd.ProcessState.ExitCode()) - - req.logEntry.Output += "OliveTin::shellAfterCompleted output complete\n" + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += fmt.Sprintf("Your shellAfterCompleted exited with code %v\n", commandExitCode(cmd)) + entry.Output += "OliveTin::shellAfterCompleted output complete\n" + }) return true } +func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout, stderr *bytes.Buffer) (*exec.Cmd, map[string]string, error) { + if req.Binding.Action.ShellAfterCompleted == "" { + return nil, nil, nil + } + + args, err := buildShellAfterArgs(req) + if err != nil { + return nil, nil, err + } + + finalParsedCommand, err := tpl.ParseTemplateWithActionContext(req.Binding.Action.ShellAfterCompleted, req.Binding.Entity, args) + if err != nil { + msg := "Could not prepare shellAfterCompleted command: " + err.Error() + "\n" + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += msg + }) + log.Warn(msg) + return nil, nil, nil + } + + cmd := wrapCommandInShell(ctx, finalParsedCommand) + cmd.Stdout = stdout + cmd.Stderr = stderr + + return cmd, args, nil +} + +func buildShellAfterArgs(req *ExecutionRequest) (map[string]string, error) { + args, err := validatedSystemArgs(req) + if err != nil { + return nil, err + } + + args["output"] = req.logEntry.Output + args["exitCode"] = fmt.Sprintf("%v", req.logEntry.ExitCode) + + return args, nil +} + //gocyclo:ignore func stepTrigger(req *ExecutionRequest) bool { if req.Binding.Action.Triggers == nil { @@ -956,7 +1293,9 @@ func stepTrigger(req *ExecutionRequest) bool { "actionTitle": req.logEntry.ActionTitle, "depth": req.TriggerDepth, }).Warnf("Trigger action reached maximum depth of %v. Not triggering further actions.", MaxTriggerDepth) - req.logEntry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth) + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output += fmt.Sprintf("OliveTin::trigger - this action reached maximum trigger depth of %v. Not triggering further actions.", MaxTriggerDepth) + }) return true } @@ -970,8 +1309,15 @@ func stepTrigger(req *ExecutionRequest) bool { } func triggerLoop(req *ExecutionRequest) { - for _, triggerReq := range req.Binding.Action.Triggers { - binding := req.executor.FindBindingByID(triggerReq) + for _, triggerTitle := range req.Binding.Action.Triggers { + binding := req.executor.findBindingByActionTitle(triggerTitle, "") + if binding == nil { + log.WithFields(log.Fields{ + "triggerTitle": triggerTitle, + "fromAction": req.logEntry.ActionTitle, + }).Warnf("Trigger references unknown action title; skipping") + continue + } trigger := &ExecutionRequest{ Binding: binding, TrackingID: uuid.NewString(), @@ -980,6 +1326,7 @@ func triggerLoop(req *ExecutionRequest) { Arguments: req.Arguments, Cfg: req.Cfg, TriggerDepth: req.TriggerDepth + 1, + Justification: fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle), } req.executor.ExecRequest(trigger) @@ -1014,7 +1361,7 @@ func saveLogResults(req *ExecutionRequest, filename string) { } filepath := path.Join(dir, filename+".yaml") - err = os.WriteFile(filepath, data, 0644) + err = os.WriteFile(filepath, data, 0600) if err != nil { log.Warnf("%v", err) @@ -1028,7 +1375,7 @@ func saveLogOutput(req *ExecutionRequest, filename string) { if dir != "" { data := req.logEntry.Output filepath := path.Join(dir, filename+".log") - err := os.WriteFile(filepath, []byte(data), 0644) + err := os.WriteFile(filepath, []byte(data), 0600) if err != nil { log.Warnf("%v", err) diff --git a/service/internal/executor/executor_actions.go b/service/internal/executor/executor_actions.go index fdf504c..75017d3 100644 --- a/service/internal/executor/executor_actions.go +++ b/service/internal/executor/executor_actions.go @@ -3,7 +3,6 @@ package executor import ( "crypto/sha256" "fmt" - "slices" config "github.com/OliveTin/OliveTin/internal/config" "github.com/OliveTin/OliveTin/internal/entities" @@ -37,26 +36,54 @@ func (e *Executor) FindBindingWithNoEntity(action *config.Action) *ActionBinding } type RebuildActionMapRequest struct { - Cfg *config.Config - DashboardActionTitles []string + Cfg *config.Config + dashboardTargets *dashboardTargetIndex +} + +func validateArgumentDefaults(cfg *config.Config) { + if cfg == nil { + return + } + for _, action := range cfg.Actions { + validateActionArgumentDefaults(action) + } +} + +func validateActionArgumentDefaults(action *config.Action) { + if action == nil { + return + } + for i := range action.Arguments { + validateArgumentDefault(action, &action.Arguments[i]) + } +} + +func validateArgumentDefault(action *config.Action, arg *config.ActionArgument) { + if arg.Default == "" { + return + } + if err := ValidateArgument(arg, arg.Default, action); err != nil { + log.WithFields(log.Fields{ + "actionTitle": action.Title, + "argName": arg.Name, + "default": arg.Default, + "error": err, + }).Warn("Argument default value failed validation") + } } func (e *Executor) RebuildActionMap() { + validateArgumentDefaults(e.Cfg) + e.MapActionBindingsLock.Lock() clear(e.MapActionBindings) req := &RebuildActionMapRequest{ - Cfg: e.Cfg, - DashboardActionTitles: make([]string, 0), + Cfg: e.Cfg, + dashboardTargets: buildDashboardTargetIndex(e.Cfg), } - findDashboardActionTitles(req) - - log.WithFields(log.Fields{ - "titles": req.DashboardActionTitles, - }).Trace("dashboardActionTitles") - for configOrder, action := range e.Cfg.Actions { if action.Entity != "" { registerActionsFromEntities(e, configOrder, action.Entity, action, req) @@ -67,52 +94,25 @@ func (e *Executor) RebuildActionMap() { e.MapActionBindingsLock.Unlock() - for _, l := range e.listeners { + for _, l := range e.copyListeners() { l.OnActionMapRebuilt() } } -func findDashboardActionTitles(req *RebuildActionMapRequest) { - for _, dashboard := range req.Cfg.Dashboards { - recurseDashboardForActionTitles(dashboard, req) - } -} - -//gocyclo:ignore -func recurseDashboardForActionTitles(component *config.DashboardComponent, req *RebuildActionMapRequest) { - for _, sub := range component.Contents { - if sub.InlineAction != nil { - title := sub.Title - if title == "" { - title = sub.InlineAction.Title - } - if title != "" { - req.DashboardActionTitles = append(req.DashboardActionTitles, title) - } - } else if sub.Type == "link" || sub.Type == "" { - req.DashboardActionTitles = append(req.DashboardActionTitles, sub.Title) - } - - if len(sub.Contents) > 0 { - recurseDashboardForActionTitles(sub, req) - } - } -} - func registerAction(e *Executor, configOrder int, action *config.Action, req *RebuildActionMapRequest) { bindingId := generateActionBindingId(action, "") e.MapActionBindings[bindingId] = &ActionBinding{ - ID: bindingId, - Action: action, - Entity: nil, - ConfigOrder: configOrder, - IsOnDashboard: slices.Contains(req.DashboardActionTitles, action.Title), + ID: bindingId, + Action: action, + Entity: nil, + ConfigOrder: configOrder, + OnDashboards: resolveOnDashboards(req.dashboardTargets, action.Title, ""), } } func registerActionsFromEntities(e *Executor, configOrder int, entityTitle string, tpl *config.Action, req *RebuildActionMapRequest) { - for _, ent := range entities.GetEntityInstances(entityTitle) { + for _, ent := range entities.GetEntityInstancesOrdered(entityTitle) { registerActionFromEntity(e, configOrder, tpl, ent, req) } } @@ -121,11 +121,11 @@ func registerActionFromEntity(e *Executor, configOrder int, tpl *config.Action, virtualActionId := generateActionBindingId(tpl, ent.UniqueKey) e.MapActionBindings[virtualActionId] = &ActionBinding{ - ID: virtualActionId, - Action: tpl, - Entity: ent, - ConfigOrder: configOrder, - IsOnDashboard: slices.Contains(req.DashboardActionTitles, tpl.Title), + ID: virtualActionId, + Action: tpl, + Entity: ent, + ConfigOrder: configOrder, + OnDashboards: resolveOnDashboards(req.dashboardTargets, tpl.Title, ent.UniqueKey), } } diff --git a/service/internal/executor/executor_dashboards.go b/service/internal/executor/executor_dashboards.go new file mode 100644 index 0000000..3e194f1 --- /dev/null +++ b/service/internal/executor/executor_dashboards.go @@ -0,0 +1,249 @@ +package executor + +import ( + "fmt" + + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/entities" +) + +type DashboardNavigationTarget struct { + Title string + EntityType string + EntityKey string + Path string +} + +func (target DashboardNavigationTarget) key() string { + return target.Title + "\x00" + target.EntityType + "\x00" + target.EntityKey +} + +func (b *ActionBinding) IsOnConfiguredDashboard() bool { + for _, dashboard := range b.OnDashboards { + if dashboard.Title != "Actions" { + return true + } + } + return false +} + +type dashboardTargetIndex struct { + byTitle map[string][]DashboardNavigationTarget + byTitleEntityKey map[string]map[string][]DashboardNavigationTarget +} + +func buildDashboardTargetIndex(cfg *config.Config) *dashboardTargetIndex { + index := &dashboardTargetIndex{ + byTitle: make(map[string][]DashboardNavigationTarget), + byTitleEntityKey: make(map[string]map[string][]DashboardNavigationTarget), + } + + for _, dashboard := range cfg.Dashboards { + walkDashboardContents(dashboard.Contents, dashboard.Title, index) + } + + return index +} + +func walkDashboardContents(contents []*config.DashboardComponent, rootDashboardTitle string, index *dashboardTargetIndex) { + for _, component := range contents { + walkDashboardComponent(component, rootDashboardTitle, index) + } +} + +func walkDashboardComponent(component *config.DashboardComponent, rootDashboardTitle string, index *dashboardTargetIndex) { + if component.Type == "fieldset" && component.Entity != "" { + walkEntityFieldset(component, rootDashboardTitle, component.Entity, index) + return + } + + recordActionTarget(component, rootDashboardTitle, "", "", index) + + if len(component.Contents) > 0 { + walkDashboardContents(component.Contents, rootDashboardTitle, index) + } +} + +func walkEntityFieldset(fieldset *config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) { + for _, component := range fieldset.Contents { + if component.Type == "directory" { + walkEntityDirectory(component, entityType, index) + continue + } + + recordActionTargetForAllEntities(component, rootDashboardTitle, entityType, index) + + if len(component.Contents) > 0 { + walkEntityFieldsetContents(component.Contents, rootDashboardTitle, entityType, index) + } + } +} + +func walkEntityFieldsetContents(contents []*config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) { + for _, component := range contents { + if component.Type == "directory" { + walkEntityDirectory(component, entityType, index) + continue + } + + recordActionTargetForAllEntities(component, rootDashboardTitle, entityType, index) + + if len(component.Contents) > 0 { + walkEntityFieldsetContents(component.Contents, rootDashboardTitle, entityType, index) + } + } +} + +func walkEntityDirectory(directory *config.DashboardComponent, entityType string, index *dashboardTargetIndex) { + for _, entity := range entities.GetEntityInstancesOrdered(entityType) { + for _, component := range directory.Contents { + recordActionTarget(component, directory.Title, entityType, entity.UniqueKey, index) + } + } +} + +func recordActionTargetForAllEntities(component *config.DashboardComponent, rootDashboardTitle, entityType string, index *dashboardTargetIndex) { + actionTitle := actionTitleFromComponent(component) + if actionTitle == "" { + return + } + + target := dashboardNavigationTarget(rootDashboardTitle, "", "") + for _, entity := range entities.GetEntityInstancesOrdered(entityType) { + addEntityTarget(index, actionTitle, entity.UniqueKey, target) + } +} + +func recordActionTarget(component *config.DashboardComponent, dashboardTitle, entityType, entityKey string, index *dashboardTargetIndex) { + actionTitle := actionTitleFromComponent(component) + if actionTitle == "" { + return + } + + target := dashboardNavigationTarget(dashboardTitle, entityType, entityKey) + if entityType != "" && entityKey != "" { + addEntityTarget(index, actionTitle, entityKey, target) + return + } + + addTitleTarget(index, actionTitle, target) +} + +func actionTitleFromComponent(component *config.DashboardComponent) string { + if title := inlineActionTitle(component); title != "" { + return title + } + + if component.Type == "link" || component.Type == "" { + return component.Title + } + + return "" +} + +func inlineActionTitle(component *config.DashboardComponent) string { + if component.InlineAction == nil { + return "" + } + + if component.Title != "" { + return component.Title + } + + return component.InlineAction.Title +} + +func dashboardNavigationTarget(title, entityType, entityKey string) DashboardNavigationTarget { + return DashboardNavigationTarget{ + Title: title, + EntityType: entityType, + EntityKey: entityKey, + Path: dashboardNavigationPath(title, entityType, entityKey), + } +} + +func dashboardNavigationPath(title, entityType, entityKey string) string { + if title == "Actions" { + return "/" + } + + if entityType != "" && entityKey != "" { + return fmt.Sprintf("/dashboards/%s/%s/%s", title, entityType, entityKey) + } + + return fmt.Sprintf("/dashboards/%s", title) +} + +func addTitleTarget(index *dashboardTargetIndex, actionTitle string, target DashboardNavigationTarget) { + index.byTitle[actionTitle] = appendUniqueTarget(index.byTitle[actionTitle], target) +} + +func addEntityTarget(index *dashboardTargetIndex, actionTitle, entityKey string, target DashboardNavigationTarget) { + if index.byTitleEntityKey[actionTitle] == nil { + index.byTitleEntityKey[actionTitle] = make(map[string][]DashboardNavigationTarget) + } + + entityTargets := index.byTitleEntityKey[actionTitle][entityKey] + index.byTitleEntityKey[actionTitle][entityKey] = appendUniqueTarget(entityTargets, target) +} + +func appendUniqueTarget(targets []DashboardNavigationTarget, target DashboardNavigationTarget) []DashboardNavigationTarget { + for _, existing := range targets { + if existing.key() == target.key() { + return targets + } + } + + return append(targets, target) +} + +func (index *dashboardTargetIndex) targetsForAction(actionTitle string) []DashboardNavigationTarget { + return dedupeTargets(index.byTitle[actionTitle]) +} + +func (index *dashboardTargetIndex) targetsForEntityAction(actionTitle, entityKey string) []DashboardNavigationTarget { + targets := make([]DashboardNavigationTarget, 0) + targets = append(targets, index.byTitle[actionTitle]...) + + if entityTargets, ok := index.byTitleEntityKey[actionTitle]; ok { + targets = append(targets, entityTargets[entityKey]...) + } + + return dedupeTargets(targets) +} + +func dedupeTargets(targets []DashboardNavigationTarget) []DashboardNavigationTarget { + if len(targets) == 0 { + return nil + } + + seen := make(map[string]bool, len(targets)) + result := make([]DashboardNavigationTarget, 0, len(targets)) + + for _, target := range targets { + key := target.key() + if seen[key] { + continue + } + + seen[key] = true + result = append(result, target) + } + + return result +} + +func resolveOnDashboards(index *dashboardTargetIndex, actionTitle, entityKey string) []DashboardNavigationTarget { + var targets []DashboardNavigationTarget + if entityKey == "" { + targets = index.targetsForAction(actionTitle) + } else { + targets = index.targetsForEntityAction(actionTitle, entityKey) + } + + if len(targets) == 0 { + return []DashboardNavigationTarget{dashboardNavigationTarget("Actions", "", "")} + } + + return targets +} diff --git a/service/internal/executor/executor_dashboards_test.go b/service/internal/executor/executor_dashboards_test.go new file mode 100644 index 0000000..dd19825 --- /dev/null +++ b/service/internal/executor/executor_dashboards_test.go @@ -0,0 +1,146 @@ +package executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/OliveTin/OliveTin/internal/entities" +) + +func TestResolveOnDashboardsDefaultsToActionsDashboard(t *testing.T) { + index := buildDashboardTargetIndex(&config.Config{}) + + targets := resolveOnDashboards(index, "Lonely Action", "") + + require.Len(t, targets, 1) + assert.Equal(t, "Actions", targets[0].Title) + assert.Equal(t, "/", targets[0].Path) +} + +func TestResolveOnDashboardsConfiguredDashboard(t *testing.T) { + cfg := &config.Config{ + Actions: []*config.Action{ + {Title: "Restart"}, + }, + Dashboards: []*config.DashboardComponent{ + { + Title: "Operations", + Contents: []*config.DashboardComponent{ + {Title: "Restart"}, + }, + }, + }, + } + + index := buildDashboardTargetIndex(cfg) + targets := resolveOnDashboards(index, "Restart", "") + + require.Len(t, targets, 1) + assert.Equal(t, "Operations", targets[0].Title) + assert.Equal(t, "/dashboards/Operations", targets[0].Path) +} + +func TestResolveOnDashboardsEntityDirectory(t *testing.T) { + cfg := &config.Config{ + Actions: []*config.Action{ + {Title: "Reboot", Entity: "host"}, + }, + Dashboards: []*config.DashboardComponent{ + { + Title: "Servers", + Contents: []*config.DashboardComponent{ + { + Type: "fieldset", + Entity: "host", + Contents: []*config.DashboardComponent{ + { + Type: "directory", + Title: "Host Details", + Contents: []*config.DashboardComponent{ + {Title: "Reboot"}, + }, + }, + }, + }, + }, + }, + }, + } + + entities.ClearEntitiesOfType("host") + defer entities.ClearEntitiesOfType("host") + entities.AddEntity("host", "host-1", map[string]any{"title": "Host 1"}) + + index := buildDashboardTargetIndex(cfg) + targets := resolveOnDashboards(index, "Reboot", "host-1") + + require.Len(t, targets, 1) + assert.Equal(t, "Host Details", targets[0].Title) + assert.Equal(t, "host", targets[0].EntityType) + assert.Equal(t, "host-1", targets[0].EntityKey) + assert.Equal(t, "/dashboards/Host Details/host/host-1", targets[0].Path) +} + +func TestRebuildActionMapStoresOnDashboards(t *testing.T) { + cfg := &config.Config{ + Actions: []*config.Action{ + {Title: "Only Default"}, + {Title: "Configured"}, + }, + Dashboards: []*config.DashboardComponent{ + { + Title: "Custom", + Contents: []*config.DashboardComponent{ + {Title: "Configured"}, + }, + }, + }, + } + + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + defaultBinding := ex.FindBindingWithNoEntity(cfg.Actions[0]) + require.NotNil(t, defaultBinding) + require.Len(t, defaultBinding.OnDashboards, 1) + assert.Equal(t, "Actions", defaultBinding.OnDashboards[0].Title) + assert.False(t, defaultBinding.IsOnConfiguredDashboard()) + + configuredBinding := ex.FindBindingWithNoEntity(cfg.Actions[1]) + require.NotNil(t, configuredBinding) + require.Len(t, configuredBinding.OnDashboards, 1) + assert.Equal(t, "Custom", configuredBinding.OnDashboards[0].Title) + assert.True(t, configuredBinding.IsOnConfiguredDashboard()) +} + +func TestResolveOnDashboardsMultipleDashboards(t *testing.T) { + cfg := &config.Config{ + Actions: []*config.Action{ + {Title: "Shared"}, + }, + Dashboards: []*config.DashboardComponent{ + { + Title: "One", + Contents: []*config.DashboardComponent{ + {Title: "Shared"}, + }, + }, + { + Title: "Two", + Contents: []*config.DashboardComponent{ + {Title: "Shared"}, + }, + }, + }, + } + + index := buildDashboardTargetIndex(cfg) + targets := resolveOnDashboards(index, "Shared", "") + + require.Len(t, targets, 2) + assert.Equal(t, "One", targets[0].Title) + assert.Equal(t, "Two", targets[1].Title) +} diff --git a/service/internal/executor/executor_test.go b/service/internal/executor/executor_test.go index f9efefe..d79f014 100644 --- a/service/internal/executor/executor_test.go +++ b/service/internal/executor/executor_test.go @@ -1,7 +1,9 @@ package executor import ( + "strings" "testing" + "time" "github.com/stretchr/testify/assert" @@ -36,7 +38,7 @@ func TestCreateExecutorAndExec(t *testing.T) { e, cfg := testingExecutor() req := ExecutionRequest{ - AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "Mr Tickle"}, + AuthenticatedUser: &authpublic.AuthenticatedUser{Username: "MrTickle"}, Cfg: cfg, Arguments: map[string]string{ "person": "yourself", @@ -295,3 +297,396 @@ func TestMangleInvalidArgumentValues(t *testing.T) { assert.Equal(t, req.logEntry.Output, "The date is: 1990-01-10T12:00:00\n", "Date should be mangled to a valid format") } + +func TestWebhookRejectsShellExecution(t *testing.T) { + cfg := config.DefaultConfig() + e := DefaultExecutor(cfg) + a1 := &config.Action{ + Title: "Webhook Shell Reject", + Shell: "echo '{{ msg }}'", + Arguments: []config.ActionArgument{ + {Name: "msg", Type: "ascii"}, + }, + } + cfg.Actions = append(cfg.Actions, a1) + cfg.Sanitize() + e.RebuildActionMap() + + req := ExecutionRequest{ + Tags: []string{"webhook"}, + AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"), + Cfg: cfg, + Arguments: map[string]string{"msg": "hello"}, + Binding: e.FindBindingWithNoEntity(a1), + } + + wg, _ := e.ExecRequest(&req) + wg.Wait() + + assert.NotNil(t, req.logEntry) + assert.Equal(t, int32(-1337), req.logEntry.ExitCode) + assert.Contains(t, req.logEntry.Output, "webhooks cannot use Shell execution") +} + +func TestWebhookAllowsExecExecution(t *testing.T) { + cfg := config.DefaultConfig() + e := DefaultExecutor(cfg) + a1 := &config.Action{ + Title: "Webhook Exec OK", + Exec: []string{"echo", "{{ msg }}"}, + Arguments: []config.ActionArgument{ + {Name: "msg", Type: "ascii"}, + }, + } + cfg.Actions = append(cfg.Actions, a1) + cfg.Sanitize() + e.RebuildActionMap() + + req := ExecutionRequest{ + Tags: []string{"webhook"}, + AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"), + Cfg: cfg, + Arguments: map[string]string{"msg": "hello"}, + Binding: e.FindBindingWithNoEntity(a1), + } + + wg, _ := e.ExecRequest(&req) + wg.Wait() + + assert.NotNil(t, req.logEntry) + assert.Equal(t, int32(0), req.logEntry.ExitCode) + assert.Contains(t, req.logEntry.Output, "hello") +} + +func TestFilterToDefinedArgumentsOnly(t *testing.T) { + req := newExecRequest() + req.Binding.Action = &config.Action{ + Title: "Filter test", + Shell: "echo '{{ name }}'", + Arguments: []config.ActionArgument{ + {Name: "name", Type: "ascii"}, + }, + } + req.Arguments = map[string]string{ + "name": "Alice", + "webhook_path": "/malicious/$(id)", + "extra_undefined": "ignored", + } + + filterToDefinedArgumentsOnly(req) + + assert.Equal(t, "Alice", req.Arguments["name"]) + assert.Empty(t, req.Arguments["webhook_path"]) + assert.Empty(t, req.Arguments["extra_undefined"]) +} + +func TestFilterToDefinedArgumentsDropsReservedPrefixArgs(t *testing.T) { + req := newExecRequest() + req.Binding.Action = &config.Action{ + Title: "Filter test", + Shell: "echo test", + Arguments: []config.ActionArgument{}, + } + req.Arguments = map[string]string{ + "ot_executionTrackingId": "track-123", + "ot_username": "webhook", + } + + filterToDefinedArgumentsOnly(req) + + assert.Empty(t, req.Arguments["ot_executionTrackingId"]) + assert.Empty(t, req.Arguments["ot_username"]) +} + +func TestStepParseArgsInjectsSystemArgsAfterFiltering(t *testing.T) { + req := newExecRequest() + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"} + req.Binding.Action = &config.Action{ + Title: "Filter then inject", + Shell: "echo test", + Arguments: []config.ActionArgument{ + {Name: "name", Type: "ascii"}, + }, + } + req.Arguments = map[string]string{ + "name": "Alice", + "ot_executionTrackingId": "attacker-track", + "ot_username": "mallory", + "ot_custom": "polluted", + } + + assert.True(t, stepParseArgs(req)) + assert.Equal(t, "Alice", req.Arguments["name"]) + assert.Equal(t, "server-track-456", req.Arguments["ot_executionTrackingId"]) + assert.Equal(t, "alice", req.Arguments["ot_username"]) + assert.Empty(t, req.Arguments["ot_custom"]) +} + +func TestStepParseArgsDropsReservedPrefixArgsFromEnvironment(t *testing.T) { + req := newExecRequest() + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"} + req.Binding.Action = &config.Action{ + Title: "No reserved prefix pollution", + Shell: "echo test", + Arguments: []config.ActionArgument{}, + } + req.Arguments = map[string]string{ + "ot_custom": "polluted", + } + + assert.True(t, stepParseArgs(req)) + env := buildEnv(req.Arguments) + + assert.False(t, containsEnvPrefix(env, "OT_CUSTOM=")) + assert.True(t, containsEnvPrefix(env, "OT_USERNAME=alice@example.com")) + assert.True(t, containsEnvPrefix(env, "OT_EXECUTIONTRACKINGID=server-track-456")) +} + +func TestSystemArgumentDefinitionsAreReservedAndShellSafe(t *testing.T) { + unsafeTypes := map[string]struct{}{ + "email": {}, + "password": {}, + "raw_string_multiline": {}, + "url": {}, + "very_dangerous_raw_string": {}, + } + seen := map[string]struct{}{} + + for _, arg := range systemArgumentDefinitions { + assert.True(t, strings.HasPrefix(arg.Name, config.ReservedArgumentNamePrefix)) + assert.NotEmpty(t, arg.Type) + assert.True(t, arg.RejectNull) + + _, duplicate := seen[arg.Name] + assert.False(t, duplicate, "duplicate system argument definition %q", arg.Name) + seen[arg.Name] = struct{}{} + + _, unsafe := unsafeTypes[arg.Type] + assert.False(t, unsafe, "system argument %q uses unsafe type %q", arg.Name, arg.Type) + } +} + +func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) { + req := newExecRequest() + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"} + + args, err := validatedSystemArgs(req) + + assert.Nil(t, err) + assert.Len(t, args, len(systemArgumentDefinitions)) + for _, arg := range systemArgumentDefinitions { + assert.Contains(t, args, arg.Name) + } +} + +func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{ + Output: "hello", + ExitCode: 7, + } + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"} + req.Binding.Action = &config.Action{ShellAfterCompleted: "echo test"} + + args, err := buildShellAfterArgs(req) + + assert.Nil(t, err) + assert.Len(t, args, len(systemArgumentDefinitions)+2) + assert.Contains(t, args, "output") + assert.Contains(t, args, "exitCode") + for _, arg := range systemArgumentDefinitions { + assert.Contains(t, args, arg.Name) + } +} + +func TestStepParseArgsAllowsEmailUsernameSystemArg(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{} + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"} + req.Binding.Action = &config.Action{ + Title: "Email username", + Shell: "echo test", + Arguments: []config.ActionArgument{}, + } + + assert.True(t, stepParseArgs(req)) + assert.Equal(t, "alice@example.com", req.Arguments["ot_username"]) +} + +func TestStepParseArgsFailsWhenUsernameSystemArgIsInvalid(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{} + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"} + req.Binding.Action = &config.Action{ + Title: "Invalid system arg", + Shell: "echo test", + Arguments: []config.ActionArgument{}, + } + + assert.False(t, stepParseArgs(req)) + assert.Contains(t, req.logEntry.Output, `system argument "ot_username" failed validation`) + assert.Empty(t, req.Arguments["ot_username"]) +} + +func TestStepParseArgsFailsWhenTrackingIDSystemArgIsInvalid(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{} + req.TrackingID = "track/../../bad" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice"} + req.Binding.Action = &config.Action{ + Title: "Invalid tracking ID", + Shell: "echo test", + Arguments: []config.ActionArgument{}, + } + + assert.False(t, stepParseArgs(req)) + assert.Contains(t, req.logEntry.Output, `system argument "ot_executionTrackingId" failed validation`) + assert.Empty(t, req.Arguments["ot_executionTrackingId"]) +} + +func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{ + Output: "hello", + ExitCode: 7, + } + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice@example.com"} + req.Binding.Action = &config.Action{ + Title: "Shell after", + ShellAfterCompleted: "echo test", + } + + args, err := buildShellAfterArgs(req) + + assert.Nil(t, err) + assert.Equal(t, "alice@example.com", args["ot_username"]) + assert.Equal(t, "server-track-456", args["ot_executionTrackingId"]) + assert.Equal(t, "hello", args["output"]) + assert.Equal(t, "7", args["exitCode"]) +} + +func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) { + req := newExecRequest() + req.logEntry = &InternalLogEntry{} + req.TrackingID = "server-track-456" + req.AuthenticatedUser = &authpublic.AuthenticatedUser{Username: "alice;id"} + req.Binding.Action = &config.Action{ + Title: "Shell after invalid username", + ShellAfterCompleted: "echo test", + } + + args, err := buildShellAfterArgs(req) + + assert.Nil(t, args) + assert.NotNil(t, err) + assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`) +} + +func containsEnvPrefix(env []string, prefix string) bool { + for _, item := range env { + if strings.HasPrefix(item, prefix) { + return true + } + } + + return false +} + +func TestTriggerExecutesTriggeredAction(t *testing.T) { + cfg := config.DefaultConfig() + e := DefaultExecutor(cfg) + helloAction := &config.Action{ + Title: "Hello world", + Shell: "echo 'Hello World!'", + } + triggerAction := &config.Action{ + Title: "Simple action that triggers another action", + Shell: "echo 'Hi'", + Triggers: []string{"Hello world"}, + } + cfg.Actions = append(cfg.Actions, helloAction, triggerAction) + cfg.Sanitize() + e.RebuildActionMap() + + finishedTitles := make(chan string, 4) + collector := &executionFinishedCollector{ch: finishedTitles} + e.AddListener(collector) + + req := &ExecutionRequest{ + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + Cfg: cfg, + Binding: e.FindBindingWithNoEntity(triggerAction), + } + wg, _ := e.ExecRequest(req) + wg.Wait() + + var got []string + for i := 0; i < 2; i++ { + select { + case title := <-finishedTitles: + got = append(got, title) + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for execution %d; got %v", i+1, got) + } + } + assert.Contains(t, got, "Hello world", "triggered action must run") + assert.Contains(t, got, "Simple action that triggers another action", "triggering action must run") +} + +func TestTriggerUnknownActionTitleSkipsWithoutPanic(t *testing.T) { + cfg := config.DefaultConfig() + e := DefaultExecutor(cfg) + triggerAction := &config.Action{ + Title: "Action with bad trigger", + Shell: "echo 'ok'", + Triggers: []string{"Nonexistent action"}, + } + cfg.Actions = append(cfg.Actions, triggerAction) + cfg.Sanitize() + e.RebuildActionMap() + + finishedTitles := make(chan string, 4) + collector := &executionFinishedCollector{ch: finishedTitles} + e.AddListener(collector) + + req := &ExecutionRequest{ + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + Cfg: cfg, + Binding: e.FindBindingWithNoEntity(triggerAction), + } + wg, _ := e.ExecRequest(req) + wg.Wait() + + var got []string + select { + case title := <-finishedTitles: + got = append(got, title) + case <-time.After(500 * time.Millisecond): + } + assert.Len(t, got, 1, "only the triggering action runs; unknown trigger is skipped") + + if len(got) > 0 { + assert.Equal(t, "Action with bad trigger", got[0]) + } +} + +type executionFinishedCollector struct { + ch chan string +} + +func (c *executionFinishedCollector) OnExecutionStarted(_ *InternalLogEntry) {} + +func (c *executionFinishedCollector) OnExecutionFinished(entry *InternalLogEntry) { + c.ch <- entry.ActionTitle +} + +func (c *executionFinishedCollector) OnOutputChunk(_ []byte, _ string) {} + +func (c *executionFinishedCollector) OnActionMapRebuilt() {} diff --git a/service/internal/executor/group_concurrency.go b/service/internal/executor/group_concurrency.go new file mode 100644 index 0000000..34f7361 --- /dev/null +++ b/service/internal/executor/group_concurrency.go @@ -0,0 +1,278 @@ +package executor + +import ( + "fmt" + "slices" + "sync" + + config "github.com/OliveTin/OliveTin/internal/config" + log "github.com/sirupsen/logrus" +) + +type groupLimit struct { + name string + maxConcurrent int + queueSize int +} + +type queuedExecution struct { + req *ExecutionRequest + wg *sync.WaitGroup +} + +func actionGroupLimits(req *ExecutionRequest) []groupLimit { + if !hasActionGroupContext(req) { + return nil + } + + limits := make([]groupLimit, 0, len(req.Binding.Action.Groups)) + + for _, groupName := range req.Binding.Action.Groups { + if limit, ok := groupLimitFromConfig(req.Cfg, groupName); ok { + limits = append(limits, limit) + } + } + + return limits +} + +func hasActionGroupContext(req *ExecutionRequest) bool { + return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Cfg != nil +} + +func groupLimitFromConfig(cfg *config.Config, groupName string) (groupLimit, bool) { + group, found := cfg.ActionGroups[groupName] + if !found || group == nil || group.MaxConcurrent < 1 { + return groupLimit{}, false + } + + return groupLimit{ + name: groupName, + maxConcurrent: group.MaxConcurrent, + queueSize: group.QueueSize, + }, true +} + +func actionNeedsGroupLimit(req *ExecutionRequest) bool { + return len(actionGroupLimits(req)) > 0 +} + +func actionInGroup(action *config.Action, groupName string) bool { + if action == nil { + return false + } + + return slices.Contains(action.Groups, groupName) +} + +func (e *Executor) countActiveInGroup(groupName string) int { + e.logmutex.RLock() + defer e.logmutex.RUnlock() + + return e.countActiveInGroupLocked(groupName) +} + +func (e *Executor) countActiveInGroupLocked(groupName string) int { + count := 0 + + for _, logEntry := range e.logs { + if logEntryIsActiveInGroup(logEntry, groupName) { + count++ + } + } + + return count +} + +func (e *Executor) countQueuedInGroupLocked(groupName string) int { + count := 0 + + for _, logEntry := range e.logs { + if queuedLogEntryInGroup(logEntry, groupName) { + count++ + } + } + + return count +} + +func queuedLogEntryInGroup(logEntry *InternalLogEntry, groupName string) bool { + if !logEntryIsBound(logEntry) { + return false + } + + if !logEntry.Queued || logEntry.ExecutionFinished { + return false + } + + return actionInGroup(logEntry.Binding.Action, groupName) +} + +func logEntryIsBound(logEntry *InternalLogEntry) bool { + return logEntry != nil && logEntry.Binding != nil && logEntry.Binding.Action != nil +} + +func groupIsAtActiveCapacity(activeCount int, limit groupLimit) bool { + return activeCount >= (limit.maxConcurrent + 1) +} + +func (e *Executor) fullGroupWithQueueExceededLocked(req *ExecutionRequest) string { + for _, limit := range actionGroupLimits(req) { + if !groupIsAtActiveCapacity(e.countActiveInGroupLocked(limit.name), limit) { + continue + } + + if e.countQueuedInGroupLocked(limit.name) >= limit.queueSize { + return limit.name + } + } + + return "" +} + +func (e *Executor) blockRequestForGroupQueue(req *ExecutionRequest, groupName string) { + log.WithFields(log.Fields{ + "actionTitle": req.logEntry.ActionTitle, + "groupName": groupName, + }).Warnf("Blocked from executing due to action group queue limit") + + req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Output = fmt.Sprintf("Blocked from executing due to action group %q queue limit", groupName) + entry.Blocked = true + }) +} + +func logEntryIsActiveInGroup(logEntry *InternalLogEntry, groupName string) bool { + if inactiveLogEntry(logEntry) { + return false + } + + return actionInGroup(logEntry.Binding.Action, groupName) +} + +func inactiveLogEntry(logEntry *InternalLogEntry) bool { + if logEntry == nil { + return true + } + + return logEntryIsInactive(logEntry) +} + +func logEntryIsInactive(logEntry *InternalLogEntry) bool { + if logEntry.ExecutionFinished || logEntry.Queued { + return true + } + + return logEntry.Binding == nil || logEntry.Binding.Action == nil +} + +func (e *Executor) groupsHaveCapacityForActive(req *ExecutionRequest) bool { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= (limit.maxConcurrent + 1) { + return false + } + } + + return true +} + +func (e *Executor) groupsHaveCapacityForQueued(req *ExecutionRequest) bool { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= limit.maxConcurrent { + return false + } + } + + return true +} + +func firstFullGroupName(e *Executor, req *ExecutionRequest) string { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroup(limit.name) >= (limit.maxConcurrent + 1) { + return limit.name + } + } + + return "" +} + +func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string { + for _, limit := range actionGroupLimits(req) { + if e.countActiveInGroupLocked(limit.name) >= (limit.maxConcurrent + 1) { + return limit.name + } + } + + return "" +} + +func (e *Executor) queueRequest(req *ExecutionRequest, wg *sync.WaitGroup) bool { + e.groupQueueMu.Lock() + + e.logmutex.RLock() + groupName := e.fullGroupWithQueueExceededLocked(req) + e.logmutex.RUnlock() + + if groupName != "" { + e.groupQueueMu.Unlock() + e.blockRequestForGroupQueue(req, groupName) + return true + } + + var waitingForGroup string + + req.mutateLogEntry(func(entry *InternalLogEntry) { + waitingForGroup = firstFullGroupNameLocked(e, req) + entry.Queued = true + entry.QueuedForGroup = waitingForGroup + entry.Output = fmt.Sprintf("Queued waiting for action group %q", waitingForGroup) + }) + + e.groupQueue = append(e.groupQueue, &queuedExecution{req: req, wg: wg}) + e.groupQueueMu.Unlock() + + e.drainGroupQueue() + + log.WithFields(log.Fields{ + "actionTitle": req.logEntry.ActionTitle, + "groupName": waitingForGroup, + }).Infof("Action queued due to action group concurrency limit") + + return false +} + +func (e *Executor) drainGroupQueue() { + e.groupQueueMu.Lock() + + if len(e.groupQueue) == 0 { + e.groupQueueMu.Unlock() + return + } + + next := e.groupQueue[0] + if !e.groupsHaveCapacityForQueued(next.req) { + e.groupQueueMu.Unlock() + return + } + + e.groupQueue = e.groupQueue[1:] + + next.req.mutateLogEntry(func(entry *InternalLogEntry) { + entry.Queued = false + entry.QueuedForGroup = "" + }) + + e.groupQueueMu.Unlock() + + go e.runDequeuedExecution(next) +} + +func (e *Executor) runDequeuedExecution(queued *queuedExecution) { + req := queued.req + + req.skipRequestRegistration = true + + e.runExecutionSteps(req) + e.finishExecChain(req) + queued.wg.Done() +} diff --git a/service/internal/executor/group_concurrency_test.go b/service/internal/executor/group_concurrency_test.go new file mode 100644 index 0000000..bd6323a --- /dev/null +++ b/service/internal/executor/group_concurrency_test.go @@ -0,0 +1,589 @@ +package executor + +import ( + "sync" + "testing" + "time" + + "github.com/OliveTin/OliveTin/internal/auth" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testGroupExecutor(actions []*config.Action, groups map[string]*config.ActionGroup) (*Executor, *config.Config) { + cfg := config.DefaultConfig() + cfg.ActionGroups = groups + cfg.Actions = actions + cfg.Sanitize() + + e := DefaultExecutor(cfg) + e.RebuildActionMap() + + return e, cfg +} + +func TestGroupConcurrencyQueuesSecondAction(t *testing.T) { + t.Parallel() + + slowAction := &config.Action{ + Title: "Unity Job 1", + Shell: "sleep 2", + Groups: []string{"unity"}, + } + fastAction := &config.Action{ + Title: "Unity Job 2", + Shell: "echo queued-run", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{slowAction, fastAction}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1}, + }, + ) + + binding1 := e.FindBindingWithNoEntity(slowAction) + binding2 := e.FindBindingWithNoEntity(fastAction) + require.NotNil(t, binding1) + require.NotNil(t, binding2) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding1, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding2, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.Queued + }, time.Second, 10*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.False(t, snapshot.Queued) + assert.False(t, snapshot.Blocked) + assert.Equal(t, int32(0), snapshot.ExitCode) + assert.Contains(t, snapshot.Output, "queued-run") +} + +func TestQueuedActionNotifiesWhenExecutionBegins(t *testing.T) { + t.Parallel() + + slowAction := &config.Action{ + Title: "Hold group", + Shell: "sleep 1", + Groups: []string{"unity"}, + } + queuedAction := &config.Action{ + Title: "Queued job", + Shell: "echo queued-run", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{slowAction, queuedAction}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1}, + }, + ) + + notifications := make(chan startedNotification, 8) + e.AddListener(&executionStartedCollector{ch: notifications}) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(slowAction), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(queuedAction), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.Queued + }, time.Second, 10*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + sawQueuedStart, sawRunningStart := collectQueuedStartNotifications(notifications, tracking2) + + assert.True(t, sawQueuedStart, "queued action should notify when queued") + assert.True(t, sawRunningStart, "queued action should notify again when execution begins") +} + +func isQueuedStartNotification(notification startedNotification, trackingID string) bool { + return notification.trackingID == trackingID && notification.queued && !notification.started +} + +func isRunningStartNotification(notification startedNotification, trackingID string) bool { + return notification.trackingID == trackingID && !notification.queued && notification.started +} + +func collectQueuedStartNotifications(notifications <-chan startedNotification, trackingID string) (sawQueuedStart, sawRunningStart bool) { + for len(notifications) > 0 { + notification := <-notifications + if isQueuedStartNotification(notification, trackingID) { + sawQueuedStart = true + } + if isRunningStartNotification(notification, trackingID) { + sawRunningStart = true + } + } + return sawQueuedStart, sawRunningStart +} + +func TestDifferentGroupsRunConcurrently(t *testing.T) { + t.Parallel() + + actionA := &config.Action{ + Title: "Group A Job", + Shell: "sleep 1", + Groups: []string{"groupA"}, + } + actionB := &config.Action{ + Title: "Group B Job", + Shell: "echo group-b", + Groups: []string{"groupB"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{actionA, actionB}, + map[string]*config.ActionGroup{ + "groupA": {MaxConcurrent: 1}, + "groupB": {MaxConcurrent: 1}, + }, + ) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(actionA), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(actionB), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.ExecutionFinished && !snapshot.Queued + }, 2*time.Second, 20*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.Contains(t, snapshot.Output, "group-b") +} + +func TestPerActionConcurrencyStillBlocksWithoutQueue(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Single binding", + Shell: "sleep 1", + MaxConcurrent: 1, + } + + e, cfg := testGroupExecutor([]*config.Action{action}, nil) + binding := e.FindBindingWithNoEntity(action) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + wg1.Wait() + wg2.Wait() + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.True(t, snapshot.Blocked) + assert.False(t, snapshot.Queued) +} + +func TestGroupedSameBindingQueuesWhenGroupFull(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Single binding grouped", + Shell: "sleep 1", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{action}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1, QueueSize: 5}, + }, + ) + binding := e.FindBindingWithNoEntity(action) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.Queued + }, time.Second, 10*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.False(t, snapshot.Blocked) +} + +func TestGroupAllowsTwoConcurrentSameBinding(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Long running action", + Shell: "sleep 1", + Groups: []string{"con2queue10"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{action}, + map[string]*config.ActionGroup{ + "con2queue10": {MaxConcurrent: 2, QueueSize: 10}, + }, + ) + binding := e.FindBindingWithNoEntity(action) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(tracking2) + return ok && snapshot.ExecutionStarted && !snapshot.Queued && !snapshot.Blocked + }, 2*time.Second, 10*time.Millisecond) + + wg1.Wait() + wg2.Wait() + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.False(t, snapshot.Blocked) + assert.False(t, snapshot.Queued) +} + +func TestGroupQueuesThirdAndBlocksWhenQueueFull(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Long running action", + Shell: "sleep 1", + Groups: []string{"con2queue10"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{action}, + map[string]*config.ActionGroup{ + "con2queue10": {MaxConcurrent: 2, QueueSize: 2}, + }, + ) + binding := e.FindBindingWithNoEntity(action) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + waitUntilExecutionStarted(t, e, tracking2) + + trackings := []string{tracking1, tracking2} + waitGroups := []*sync.WaitGroup{wg1, wg2} + + for idx := 0; idx < 3; idx++ { + wg, tracking := e.ExecRequest(&ExecutionRequest{ + Binding: binding, + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + trackings = append(trackings, tracking) + waitGroups = append(waitGroups, wg) + } + + require.Eventually(t, func() bool { + return groupExecutionDistributionMatches(e, trackings, 2, 2, 1) + }, 2*time.Second, 20*time.Millisecond) + + for _, wg := range waitGroups { + wg.Wait() + } +} + +func waitUntilExecutionStarted(t *testing.T, e *Executor, trackingID string) { + t.Helper() + + require.Eventually(t, func() bool { + snapshot, ok := e.SnapshotLog(trackingID) + return ok && snapshot.ExecutionStarted + }, 2*time.Second, 10*time.Millisecond) +} + +type executionStartedCollector struct { + ch chan startedNotification +} + +type startedNotification struct { + trackingID string + started bool + queued bool +} + +func (c *executionStartedCollector) OnExecutionStarted(entry *InternalLogEntry) { + c.ch <- startedNotification{ + trackingID: entry.ExecutionTrackingID, + started: entry.ExecutionStarted, + queued: entry.Queued, + } +} + +func (c *executionStartedCollector) OnExecutionFinished(_ *InternalLogEntry) {} + +func (c *executionStartedCollector) OnOutputChunk(_ []byte, _ string) {} + +func (c *executionStartedCollector) OnActionMapRebuilt() {} + +func assertWaitGroupPending(t *testing.T, wg *sync.WaitGroup) { + t.Helper() + + done := make(chan struct{}) + + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("wait group completed before queued execution finished") + case <-time.After(100 * time.Millisecond): + } +} + +func assertWaitGroupCompletes(t *testing.T, wg *sync.WaitGroup) { + t.Helper() + + done := make(chan struct{}) + + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("wait group did not complete after queue drained") + } +} + +func TestStartActionAndWaitWaitsForQueuedExecution(t *testing.T) { + t.Parallel() + + first := &config.Action{ + Title: "Hold group", + Shell: "sleep 1", + Groups: []string{"unity"}, + } + second := &config.Action{ + Title: "Wait in queue", + Shell: "echo waited", + Groups: []string{"unity"}, + } + + e, cfg := testGroupExecutor( + []*config.Action{first, second}, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1}, + }, + ) + + wg1, tracking1 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(first), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + waitUntilExecutionStarted(t, e, tracking1) + + wg2, tracking2 := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(second), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + assertWaitGroupPending(t, wg2) + + wg1.Wait() + + assertWaitGroupCompletes(t, wg2) + + snapshot, ok := e.SnapshotLog(tracking2) + require.True(t, ok) + assert.Contains(t, snapshot.Output, "waited") +} + +func TestGroupQueueBlocksWhenQueueFull(t *testing.T) { + t.Parallel() + + actions := []*config.Action{ + {Title: "Hold 1", Shell: "sleep 1", Groups: []string{"unity"}}, + {Title: "Hold 2", Shell: "sleep 1", Groups: []string{"unity"}}, + {Title: "Hold 3", Shell: "sleep 1", Groups: []string{"unity"}}, + {Title: "Hold 4", Shell: "sleep 1", Groups: []string{"unity"}}, + } + + e, cfg := testGroupExecutor( + actions, + map[string]*config.ActionGroup{ + "unity": {MaxConcurrent: 1, QueueSize: 2}, + }, + ) + + trackings, waitGroups := execAllGroupActions(t, e, cfg, actions) + + require.Eventually(t, func() bool { + return countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) == 1 && + countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued }) == 2 && + countSnapshots(e, trackings, isRunningSnapshot) == 1 + }, 2*time.Second, 20*time.Millisecond) + + for _, wg := range waitGroups { + wg.Wait() + } +} + +func execAllGroupActions(t *testing.T, e *Executor, cfg *config.Config, actions []*config.Action) ([]string, []*sync.WaitGroup) { + t.Helper() + + trackings := make([]string, len(actions)) + waitGroups := make([]*sync.WaitGroup, len(actions)) + + for idx, action := range actions { + wg, tracking := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(action), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + trackings[idx] = tracking + waitGroups[idx] = wg + } + + return trackings, waitGroups +} + +func groupExecutionDistributionMatches(e *Executor, trackings []string, wantRunning, wantQueued, wantBlocked int) bool { + running := countSnapshots(e, trackings, isRunningSnapshot) + queued := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Queued }) + blocked := countSnapshots(e, trackings, func(snapshot LogEntrySnapshot) bool { return snapshot.Blocked }) + return running == wantRunning && queued == wantQueued && blocked == wantBlocked +} + +func countSnapshots(e *Executor, trackings []string, matches func(LogEntrySnapshot) bool) int { + count := 0 + + for _, tracking := range trackings { + snapshot, ok := e.SnapshotLog(tracking) + if ok && matches(snapshot) { + count++ + } + } + + return count +} + +func isRunningSnapshot(snapshot LogEntrySnapshot) bool { + return snapshot.ExecutionStarted && !snapshot.ExecutionFinished +} + +func TestUnknownActionGroupReferenceWarnsAndSkipsLimit(t *testing.T) { + t.Parallel() + + action := &config.Action{ + Title: "Unknown group action", + Shell: "echo ok", + Groups: []string{"missing"}, + } + + e, cfg := testGroupExecutor([]*config.Action{action}, map[string]*config.ActionGroup{}) + wg, tracking := e.ExecRequest(&ExecutionRequest{ + Binding: e.FindBindingWithNoEntity(action), + Cfg: cfg, + AuthenticatedUser: auth.UserFromSystem(cfg, "testuser"), + }) + + wg.Wait() + + snapshot, ok := e.SnapshotLog(tracking) + require.True(t, ok) + assert.False(t, snapshot.Queued) + assert.Equal(t, int32(0), snapshot.ExitCode) +} diff --git a/service/internal/executor/justification.go b/service/internal/executor/justification.go new file mode 100644 index 0000000..55542c9 --- /dev/null +++ b/service/internal/executor/justification.go @@ -0,0 +1,68 @@ +package executor + +import ( + "fmt" + "strings" + + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" +) + +const ( + justificationCron = "Triggered by cron" + justificationStartup = "Triggered by startup" + justificationFileChange = "Triggered by file change" + justificationCalendar = "Triggered by calendar" + justificationWebhook = "Triggered by webhook" +) + +var systemJustificationDefaults = map[string]string{ + "cron": justificationCron, + "startup": justificationStartup, + "fileindir": justificationFileChange, + "calendar": justificationCalendar, + "webhook": justificationWebhook, +} + +func IsSystemExecution(user *authpublic.AuthenticatedUser) bool { + if user == nil || user.Provider != "system" { + return false + } + + return user.Username != "guest" +} + +func ResolveJustification(req *ExecutionRequest) string { + provided := strings.TrimSpace(reqJustification(req)) + if provided != "" { + return provided + } + + if !actionRequiresJustification(req) { + return "" + } + + return defaultJustificationForRequest(req) +} + +func actionRequiresJustification(req *ExecutionRequest) bool { + return req != nil && req.Binding != nil && req.Binding.Action != nil && req.Binding.Action.Justification +} + +func defaultJustificationForRequest(req *ExecutionRequest) string { + if req.TriggerDepth > 0 && req.logEntry != nil { + return fmt.Sprintf("Triggered by action: %s", req.logEntry.ActionTitle) + } + + if req.AuthenticatedUser == nil { + return "" + } + + return systemJustificationDefaults[req.AuthenticatedUser.Username] +} + +func reqJustification(req *ExecutionRequest) string { + if req == nil { + return "" + } + return req.Justification +} diff --git a/service/internal/executor/justification_test.go b/service/internal/executor/justification_test.go new file mode 100644 index 0000000..c479760 --- /dev/null +++ b/service/internal/executor/justification_test.go @@ -0,0 +1,130 @@ +package executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/OliveTin/OliveTin/internal/auth" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func TestResolveJustificationUsesProvidedValue(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Send email", Justification: true, Shell: "echo hi"} + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + Justification: "New user registration foo@example.com", + AuthenticatedUser: auth.UserGuest(cfg), + Cfg: cfg, + } + req.logEntry = &InternalLogEntry{} + + assert.Equal(t, "New user registration foo@example.com", ResolveJustification(req)) +} + +func TestResolveJustificationCronDefault(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Nightly backup", Justification: true, Shell: "echo hi"} + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + AuthenticatedUser: auth.UserFromSystem(cfg, "cron"), + Cfg: cfg, + } + + assert.Equal(t, justificationCron, ResolveJustification(req)) +} + +func TestResolveJustificationStartupDefault(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Init", Justification: true, Shell: "echo hi"} + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + AuthenticatedUser: auth.UserFromSystem(cfg, "startup"), + Cfg: cfg, + } + + assert.Equal(t, justificationStartup, ResolveJustification(req)) +} + +func TestResolveJustificationWebhookDefault(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Deploy", Justification: true, Exec: []string{"echo", "deploy"}} + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + AuthenticatedUser: auth.UserFromSystem(cfg, "webhook"), + Cfg: cfg, + } + + assert.Equal(t, justificationWebhook, ResolveJustification(req)) +} + +func TestResolveJustificationEmptyWhenNotRequired(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{Title: "Ping", Shell: "echo hi"} + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + AuthenticatedUser: auth.UserGuest(cfg), + Cfg: cfg, + } + + assert.Empty(t, ResolveJustification(req)) +} + +func TestJustificationNotPassedToShellArgs(t *testing.T) { + cfg := config.DefaultConfig() + action := &config.Action{ + Title: "Echo", + Justification: true, + Shell: "echo {{ message }}", + Arguments: []config.ActionArgument{ + {Name: "message", Type: "ascii_sentence"}, + }, + } + cfg.Actions = append(cfg.Actions, action) + ex := DefaultExecutor(cfg) + ex.RebuildActionMap() + + req := &ExecutionRequest{ + Binding: ex.FindBindingWithNoEntity(action), + Arguments: map[string]string{ + "message": "hello", + "justification": "should be stripped", + }, + Justification: "audit reason", + AuthenticatedUser: auth.UserGuest(cfg), + Cfg: cfg, + } + req.logEntry = &InternalLogEntry{} + + filterToDefinedArgumentsOnly(req) + + assert.Equal(t, "hello", req.Arguments["message"]) + assert.Empty(t, req.Arguments["justification"]) +} + +func TestIsSystemExecution(t *testing.T) { + cfg := config.DefaultConfig() + assert.True(t, IsSystemExecution(auth.UserFromSystem(cfg, "cron"))) + assert.False(t, IsSystemExecution(auth.UserGuest(cfg))) +} diff --git a/service/internal/executor/logfilter.go b/service/internal/executor/logfilter.go new file mode 100644 index 0000000..7a3218c --- /dev/null +++ b/service/internal/executor/logfilter.go @@ -0,0 +1,63 @@ +package executor + +import ( + "fmt" + + "github.com/OliveTin/OliveTin/internal/logfilter" + "github.com/expr-lang/expr/vm" +) + +func filterRecordFromEntry(entry *InternalLogEntry) logfilter.Record { + return logfilter.Record{ + Status: logfilter.StatusLabel(entry.ExecutionFinished, entry.Blocked, entry.TimedOut, entry.Queued), + Action: entry.ActionTitle, + User: entry.Username, + Tags: entry.Tags, + Blocked: entry.Blocked, + TimedOut: entry.TimedOut, + Running: !entry.ExecutionFinished, + ExitCode: entry.ExitCode, + Output: entry.Output, + } +} + +func applyLogFilter(entries []*InternalLogEntry, program *vm.Program) ([]*InternalLogEntry, error) { + if program == nil { + return entries, nil + } + return filterEntries(entries, program) +} + +func filterEntries(entries []*InternalLogEntry, program *vm.Program) ([]*InternalLogEntry, error) { + filtered := make([]*InternalLogEntry, 0, len(entries)) + for _, entry := range entries { + var err error + filtered, err = appendMatchingEntry(filtered, entry, program) + if err != nil { + return nil, err + } + } + return filtered, nil +} + +func appendMatchingEntry(filtered []*InternalLogEntry, entry *InternalLogEntry, program *vm.Program) ([]*InternalLogEntry, error) { + if entry == nil { + return nil, fmt.Errorf("log entry is nil") + } + matched, err := entryMatchesFilter(entry, program) + if err != nil { + return nil, err + } + if matched { + filtered = append(filtered, entry) + } + return filtered, nil +} + +func entryMatchesFilter(entry *InternalLogEntry, program *vm.Program) (bool, error) { + matched, err := logfilter.Matches(program, filterRecordFromEntry(entry)) + if err != nil { + return false, fmt.Errorf("filter evaluation failed: %w", err) + } + return matched, nil +} diff --git a/service/internal/executor/logfilter_test.go b/service/internal/executor/logfilter_test.go new file mode 100644 index 0000000..5401d9a --- /dev/null +++ b/service/internal/executor/logfilter_test.go @@ -0,0 +1,17 @@ +package executor + +import ( + "testing" + + "github.com/OliveTin/OliveTin/internal/logfilter" + "github.com/stretchr/testify/require" +) + +func TestFilterEntriesRejectsNilEntry(t *testing.T) { + program, err := logfilter.Compile(`Status == Completed`) + require.NoError(t, err) + + _, err = filterEntries([]*InternalLogEntry{nil}, program) + require.Error(t, err) + require.Contains(t, err.Error(), "log entry is nil") +} diff --git a/service/internal/executor/queue.go b/service/internal/executor/queue.go new file mode 100644 index 0000000..30674d7 --- /dev/null +++ b/service/internal/executor/queue.go @@ -0,0 +1,36 @@ +package executor + +import ( + acl "github.com/OliveTin/OliveTin/internal/acl" + authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic" + config "github.com/OliveTin/OliveTin/internal/config" +) + +func isActiveQueueEntry(entry *InternalLogEntry) bool { + return entry != nil && !entry.ExecutionFinished +} + +func isQueueEntryVisible(cfg *config.Config, user *authpublic.AuthenticatedUser, entry *InternalLogEntry) bool { + if !isActiveQueueEntry(entry) || !isValidLogEntryForACL(entry) { + return false + } + + return acl.IsAllowedLogs(cfg, user, entry.Binding.Action) +} + +// GetActiveExecutionsACL returns unfinished executions the user may view in the queue. +func (e *Executor) GetActiveExecutionsACL(cfg *config.Config, user *authpublic.AuthenticatedUser) []*InternalLogEntry { + e.logmutex.RLock() + defer e.logmutex.RUnlock() + + active := make([]*InternalLogEntry, 0) + + for _, trackingID := range e.logsTrackingIdsByDate { + entry := e.logs[trackingID] + if isQueueEntryVisible(cfg, user, entry) { + active = append(active, entry) + } + } + + return active +} diff --git a/service/internal/executor/queue_test.go b/service/internal/executor/queue_test.go new file mode 100644 index 0000000..e556130 --- /dev/null +++ b/service/internal/executor/queue_test.go @@ -0,0 +1,72 @@ +package executor + +import ( + "testing" + "time" + + auth "github.com/OliveTin/OliveTin/internal/auth" + config "github.com/OliveTin/OliveTin/internal/config" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetActiveExecutionsACLFiltersFinishedAndACL(t *testing.T) { + e, cfg := testingExecutor() + + allowedAction := &config.Action{ + Title: "allowed", + Shell: "sleep 1", + Acls: []string{"view-logs"}, + } + secretAction := &config.Action{ + Title: "secret", + Shell: "sleep 1", + } + cfg.Actions = append(cfg.Actions, allowedAction, secretAction) + cfg.DefaultPermissions.Logs = false + cfg.AccessControlLists = []*config.AccessControlList{ + { + Name: "view-logs", + MatchUsernames: []string{"guest"}, + Permissions: config.PermissionsList{ + Logs: true, + }, + }, + } + cfg.Sanitize() + e.RebuildActionMap() + + allowedBinding := e.FindBindingWithNoEntity(allowedAction) + secretBinding := e.FindBindingWithNoEntity(secretAction) + require.NotNil(t, allowedBinding) + require.NotNil(t, secretBinding) + + activeAllowed := newQueueTestLogEntry(allowedBinding, false) + finishedAllowed := newQueueTestLogEntry(allowedBinding, true) + activeSecret := newQueueTestLogEntry(secretBinding, false) + + e.SetLog(activeAllowed.ExecutionTrackingID, activeAllowed) + e.SetLog(finishedAllowed.ExecutionTrackingID, finishedAllowed) + e.SetLog(activeSecret.ExecutionTrackingID, activeSecret) + + user := auth.UserGuest(cfg) + active := e.GetActiveExecutionsACL(cfg, user) + + require.Len(t, active, 1) + assert.Equal(t, activeAllowed.ExecutionTrackingID, active[0].ExecutionTrackingID) +} + +func newQueueTestLogEntry(binding *ActionBinding, finished bool) *InternalLogEntry { + entry := &InternalLogEntry{ + Binding: binding, + DatetimeStarted: time.Now(), + ExecutionTrackingID: uuid.NewString(), + ActionTitle: binding.Action.Title, + ExecutionFinished: finished, + } + if finished { + entry.DatetimeFinished = time.Now() + } + return entry +} diff --git a/service/internal/httpservers/frontend.go b/service/internal/httpservers/frontend.go index 9860650..e317b18 100644 --- a/service/internal/httpservers/frontend.go +++ b/service/internal/httpservers/frontend.go @@ -13,6 +13,7 @@ import ( "net/http/httputil" "net/url" "path" + "strings" "github.com/OliveTin/OliveTin/internal/api" "github.com/OliveTin/OliveTin/internal/auth" @@ -23,13 +24,69 @@ import ( log "github.com/sirupsen/logrus" ) +func applySecurityHeaders(cfg *config.Config, w http.ResponseWriter) { + applyCSP(cfg, w) + applyXContentTypeOptions(cfg, w) + applyXFrameOptions(cfg, w) +} + +func applyCSP(cfg *config.Config, w http.ResponseWriter) { + if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy == "" { + return + } + w.Header().Set("Content-Security-Policy", cfg.Security.ContentSecurityPolicy) +} + +func applyXContentTypeOptions(cfg *config.Config, w http.ResponseWriter) { + if !cfg.Security.HeaderXContentTypeOptions { + return + } + w.Header().Set("X-Content-Type-Options", "nosniff") +} + +func applyXFrameOptions(cfg *config.Config, w http.ResponseWriter) { + if !cfg.Security.HeaderXFrameOptions || cfg.Security.XFrameOptions == "" { + return + } + w.Header().Set("X-Frame-Options", cfg.Security.XFrameOptions) +} + +func securityHeadersMiddleware(cfg *config.Config, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + applySecurityHeaders(cfg, w) + next.ServeHTTP(w, r) + }) +} + +func isSensitiveLogHeaderName(name string) bool { + switch strings.ToLower(name) { + case "authorization", "cookie", "x-forwarded-access-token": + return true + default: + return false + } +} + +func redactHeaderValuesForLog(name string, values []string) []string { + if !isSensitiveLogHeaderName(name) { + return values + } + + out := make([]string, len(values)) + for i := range values { + out[i] = "[redacted]" + } + + return out +} + func logDebugRequest(cfg *config.Config, source string, r *http.Request) { if cfg.LogDebugOptions.SingleFrontendRequests { log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL) if cfg.LogDebugOptions.SingleFrontendRequestHeaders { for name, values := range r.Header { - log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, values) + log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, redactHeaderValuesForLog(name, values)) } } } @@ -67,6 +124,7 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { oauth2handler := otoauth2.NewOAuth2Handler(cfg) auth.AddAuthChainFunction(oauth2handler.CheckUserFromOAuth2Cookie) + auth.RegisterOAuth2SessionRevoker(oauth2handler.RevokeSession) mux.HandleFunc("/oauth/login", oauth2handler.HandleOAuthLogin) mux.HandleFunc("/oauth/callback", oauth2handler.HandleOAuthCallback) @@ -96,7 +154,7 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) { srv := &http.Server{ Addr: cfg.ListenAddressSingleHTTPFrontend, - Handler: mux, + Handler: securityHeadersMiddleware(cfg, mux), } log.Fatal(srv.ListenAndServe()) diff --git a/service/internal/httpservers/frontend_test.go b/service/internal/httpservers/frontend_test.go new file mode 100644 index 0000000..f44c2c5 --- /dev/null +++ b/service/internal/httpservers/frontend_test.go @@ -0,0 +1,17 @@ +package httpservers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactHeaderValuesForLog(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("Authorization", []string{"Bearer secret"})) + assert.Equal(t, []string{"[redacted]", "[redacted]"}, redactHeaderValuesForLog("Cookie", []string{"a=1", "b=2"})) + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("authorization", []string{"x"})) + assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("X-Forwarded-Access-Token", []string{"jwt"})) + assert.Equal(t, []string{"https"}, redactHeaderValuesForLog("X-Forwarded-Proto", []string{"https"})) +} diff --git a/service/internal/httpservers/webuiServer.go b/service/internal/httpservers/webuiServer.go index fb718ba..5b76c06 100644 --- a/service/internal/httpservers/webuiServer.go +++ b/service/internal/httpservers/webuiServer.go @@ -1,8 +1,6 @@ package httpservers import ( - - // cors "github.com/OliveTin/OliveTin/internal/cors" "net/http" "os" "path" diff --git a/service/internal/installationinfo/sosreport.go b/service/internal/installationinfo/sosreport.go index 3dd1e77..4d6c2ce 100644 --- a/service/internal/installationinfo/sosreport.go +++ b/service/internal/installationinfo/sosreport.go @@ -40,15 +40,23 @@ func configToSosreport(cfg *config.Config) *sosReportConfig { } } -func GetSosReport() string { +func GetSosReport(redactVersion bool) string { ret := "" ret += "### SOSREPORT START (copy all text to SOSREPORT END)\n" - out, _ := yaml.Marshal(Build) + buildForReport := *Build + if redactVersion { + buildForReport.Version = "[redacted]" + } + out, _ := yaml.Marshal(&buildForReport) ret += fmt.Sprintf("# Build: \n%+v\n", string(out)) - out, _ = yaml.Marshal(Runtime) + runtimeForReport := *Runtime + if redactVersion { + runtimeForReport.AvailableVersion = "[redacted]" + } + out, _ = yaml.Marshal(&runtimeForReport) ret += fmt.Sprintf("# Runtime:\n%+v\n", string(out)) out, _ = yaml.Marshal(configToSosreport(Config)) diff --git a/service/internal/logfilter/filter.go b/service/internal/logfilter/filter.go new file mode 100644 index 0000000..a19acd9 --- /dev/null +++ b/service/internal/logfilter/filter.go @@ -0,0 +1,216 @@ +package logfilter + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" +) + +const maxFilterLength = 512 + +var ( + comparePattern = regexp.MustCompile(`(?i)\b(Status|Action|User|ExitCode|Blocked|TimedOut|Running)\s*(==|!=)\s*("[^"]*"|\S+)`) + containsPattern = regexp.MustCompile(`(?i)\b(Status|Action|User|Output)\s+contains\s+("[^"]*"|\S+)`) + + fieldNameByLower = map[string]string{ + "status": "Status", + "action": "Action", + "user": "User", + "exitcode": "ExitCode", + "blocked": "Blocked", + "timedout": "TimedOut", + "running": "Running", + "output": "Output", + } +) + +// Compile parses and compiles a filter expression. Returns an error for invalid syntax. +func Compile(expression string) (*vm.Program, error) { + trimmed := strings.TrimSpace(expression) + if trimmed == "" { + return nil, nil + } + if len(trimmed) > maxFilterLength { + return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength) + } + + normalized, err := normalizeExpression(trimmed) + if err != nil { + return nil, err + } + + return compileNormalized(normalized) +} + +func compileNormalized(normalized string) (*vm.Program, error) { + return expr.Compile(normalized, + expr.Env(Record{}), + expr.AsBool(), + expr.Function("includes", includes), + expr.Function("hasTag", hasTag), + ) +} + +func includes(params ...any) (any, error) { + if len(params) < 2 { + return nil, fmt.Errorf("includes expects 2 arguments, got %d", len(params)) + } + haystack, ok := params[0].(string) + if !ok { + return nil, fmt.Errorf("expected string for haystack") + } + needle, ok := params[1].(string) + if !ok { + return nil, fmt.Errorf("expected string for needle") + } + return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle)), nil +} + +func hasTag(params ...any) (any, error) { + if len(params) < 2 { + return nil, fmt.Errorf("hasTag expects 2 arguments, got %d", len(params)) + } + tags, ok := params[0].([]string) + if !ok { + return nil, fmt.Errorf("expected []string for tags") + } + needle, ok := params[1].(string) + if !ok { + return nil, fmt.Errorf("expected string for needle") + } + return tagListIncludes(tags, needle), nil +} + +func tagListIncludes(tags []string, needle string) bool { + needle = strings.ToLower(needle) + for _, tag := range tags { + if strings.Contains(strings.ToLower(tag), needle) { + return true + } + } + return false +} + +// Matches evaluates a compiled filter against a log record. +func Matches(program *vm.Program, record Record) (bool, error) { + if program == nil { + return true, nil + } + + result, err := expr.Run(program, record) + if err != nil { + return false, err + } + + matched, ok := result.(bool) + if !ok { + return false, fmt.Errorf("filter expression must return a boolean") + } + + return matched, nil +} + +func normalizeExpression(expression string) (string, error) { + if isNegatedSearchTerm(expression) { + term := quoteLiteral(strings.TrimPrefix(expression, "!")) + return negatedSearchExpression(term), nil + } + + if isPositiveSearchTerm(expression) { + return positiveSearchExpression(quoteLiteral(expression)), nil + } + + normalized := replaceContainsOperators(expression) + normalized = replaceComparisons(normalized) + return replaceBooleanWords(normalized), nil +} + +func isNegatedSearchTerm(expression string) bool { + if !strings.HasPrefix(expression, "!") { + return false + } + remainder := strings.TrimSpace(expression[1:]) + return remainder != "" && !containsExpressionOperators(remainder) +} + +func isPositiveSearchTerm(expression string) bool { + return expression != "" && !containsExpressionOperators(expression) +} + +func containsExpressionOperators(expression string) bool { + lower := strings.ToLower(expression) + operators := []string{"==", "!=", "&&", "||", " contains ", "(", ")"} + for _, operator := range operators { + if strings.Contains(lower, operator) { + return true + } + } + return false +} + +func negatedSearchExpression(term string) string { + return "!(" + positiveSearchExpression(term) + ")" +} + +func positiveSearchExpression(term string) string { + return "includes(Action, " + term + ") || includes(User, " + term + ") || includes(Status, " + term + ") || includes(Output, " + term + ") || hasTag(Tags, " + term + ")" +} + +func replaceContainsOperators(expression string) string { + return containsPattern.ReplaceAllStringFunc(expression, func(match string) string { + parts := containsPattern.FindStringSubmatch(match) + field := normalizeFieldName(parts[1]) + value := quoteIfNeeded(parts[2]) + return fmt.Sprintf("includes(%s, %s)", field, value) + }) +} + +func replaceComparisons(expression string) string { + return comparePattern.ReplaceAllStringFunc(expression, func(match string) string { + parts := comparePattern.FindStringSubmatch(match) + field := normalizeFieldName(parts[1]) + operator := parts[2] + value := quoteIfNeeded(parts[3]) + return fmt.Sprintf("%s %s %s", field, operator, value) + }) +} + +func normalizeFieldName(field string) string { + if canonical, ok := fieldNameByLower[strings.ToLower(field)]; ok { + return canonical + } + return field +} + +func replaceBooleanWords(expression string) string { + replacer := strings.NewReplacer(" and ", " && ", " AND ", " && ", " or ", " || ", " OR ", " || ") + return replacer.Replace(expression) +} + +func quoteIfNeeded(value string) string { + if strings.HasPrefix(value, "\"") { + return value + } + if isBooleanLiteral(value) || isIntegerLiteral(value) { + return strings.ToLower(value) + } + return quoteLiteral(value) +} + +func isBooleanLiteral(value string) bool { + lower := strings.ToLower(value) + return lower == "true" || lower == "false" +} + +func isIntegerLiteral(value string) bool { + _, err := strconv.ParseInt(value, 10, 64) + return err == nil +} + +func quoteLiteral(value string) string { + return "\"" + strings.ReplaceAll(value, "\"", "\\\"") + "\"" +} diff --git a/service/internal/logfilter/filter_test.go b/service/internal/logfilter/filter_test.go new file mode 100644 index 0000000..fa6f84d --- /dev/null +++ b/service/internal/logfilter/filter_test.go @@ -0,0 +1,106 @@ +package logfilter + +import ( + "testing" + + "github.com/expr-lang/expr/vm" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompileNegatedSearchTerm(t *testing.T) { + program, err := Compile("!Update") + require.NoError(t, err) + + assert.False(t, mustMatch(t, program, Record{Action: "Run Update script"})) + assert.True(t, mustMatch(t, program, Record{Action: "Ping host"})) +} + +func TestCompileStatusNotEqual(t *testing.T) { + program, err := Compile("Status != Completed") + require.NoError(t, err) + + assert.True(t, mustMatch(t, program, Record{Status: "Blocked"})) + assert.False(t, mustMatch(t, program, Record{Status: "Completed"})) +} + +func TestCompileContainsAndBooleanWords(t *testing.T) { + program, err := Compile(`Status == Completed and Action contains backup`) + require.NoError(t, err) + + assert.True(t, mustMatch(t, program, Record{Status: "Completed", Action: "Nightly backup"})) + assert.False(t, mustMatch(t, program, Record{Status: "Blocked", Action: "Nightly backup"})) +} + +func TestCompileNormalizesFieldNamesAndValueTypes(t *testing.T) { + cases := []struct { + expression string + record Record + want bool + }{ + {"status == completed", Record{Status: "completed"}, true}, + {"ExitCode == 0", Record{ExitCode: 0}, true}, + {"exitcode == 0", Record{ExitCode: 0}, true}, + {"Blocked == true", Record{Blocked: true}, true}, + {"blocked == false", Record{Blocked: false}, true}, + } + for _, tc := range cases { + t.Run(tc.expression, func(t *testing.T) { + program, err := Compile(tc.expression) + require.NoError(t, err) + assert.Equal(t, tc.want, mustMatch(t, program, tc.record)) + }) + } +} + +func TestCompileRejectsOverlongExpression(t *testing.T) { + _, err := Compile(string(make([]byte, maxFilterLength+1))) + require.Error(t, err) +} + +func TestCompileRejectsUnknownField(t *testing.T) { + _, err := Compile(`SecretField == "x"`) + require.Error(t, err) +} + +func TestIncludesReturnsErrorsForMalformedArguments(t *testing.T) { + _, err := includes("only-one") + require.Error(t, err) + + _, err = includes(123, "needle") + require.Error(t, err) + require.Contains(t, err.Error(), "haystack") + + _, err = includes("hay", 123) + require.Error(t, err) + require.Contains(t, err.Error(), "needle") +} + +func TestHasTagReturnsErrorsForMalformedArguments(t *testing.T) { + _, err := hasTag("only-one") + require.Error(t, err) + + _, err = hasTag("not-tags", "x") + require.Error(t, err) + require.Contains(t, err.Error(), "tags") + + _, err = hasTag([]string{"a"}, 123) + require.Error(t, err) + require.Contains(t, err.Error(), "needle") +} + +func TestMatchesSurfacesIncludesTypeErrors(t *testing.T) { + program, err := Compile(`Action contains 123`) + require.NoError(t, err) + + _, err = Matches(program, Record{Action: "test 123"}) + require.Error(t, err) + require.Contains(t, err.Error(), "needle") +} + +func mustMatch(t *testing.T, program *vm.Program, record Record) bool { + t.Helper() + matched, err := Matches(program, record) + require.NoError(t, err) + return matched +} diff --git a/service/internal/logfilter/record.go b/service/internal/logfilter/record.go new file mode 100644 index 0000000..6315a7b --- /dev/null +++ b/service/internal/logfilter/record.go @@ -0,0 +1,35 @@ +package logfilter + +// Record exposes only log fields that may be used in filter expressions. +type Record struct { + Status string + Action string + User string + Tags []string + Blocked bool + TimedOut bool + Running bool + ExitCode int32 + Output string +} + +// StatusLabel matches the status text shown in the web UI. +func StatusLabel(executionFinished, blocked, timedOut, queued bool) string { + if !executionFinished { + if queued { + return "Queued" + } + return "Running" + } + return finishedStatusLabel(blocked, timedOut) +} + +func finishedStatusLabel(blocked, timedOut bool) string { + if blocked { + return "Blocked" + } + if timedOut { + return "Timed out" + } + return "Completed" +} diff --git a/service/internal/onfileindir/fileindir.go b/service/internal/onfileindir/fileindir.go index 40bd19c..b6085d1 100644 --- a/service/internal/onfileindir/fileindir.go +++ b/service/internal/onfileindir/fileindir.go @@ -13,6 +13,13 @@ import ( func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) { for _, action := range cfg.Actions { + for _, dirname := range action.ExecOnFileCreatedInDir { + go func(act *config.Action, dir string) { + filehelper.WatchDirectoryCreate(dir, func(filename string) { + scheduleExec(act, cfg, ex, filename) + }) + }(action, dirname) + } for _, dirname := range action.ExecOnFileChangedInDir { // Pass values into anonymous function because of this issue // https://github.com/OliveTin/OliveTin/issues/503 @@ -22,12 +29,6 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) { scheduleExec(act, cfg, ex, filename) }) }(action, dirname) - - go func(act *config.Action, dir string) { - filehelper.WatchDirectoryCreate(dir, func(filename string) { - scheduleExec(act, cfg, ex, filename) - }) - }(action, dirname) } } } diff --git a/service/internal/servicehost/log_directory.go b/service/internal/servicehost/log_directory.go new file mode 100644 index 0000000..1d52601 --- /dev/null +++ b/service/internal/servicehost/log_directory.go @@ -0,0 +1,44 @@ +package servicehost + +import ( + "os" + "path/filepath" +) + +func resolveLogDirectory(dir string, baseDir string) string { + if dir == "" { + return "" + } + + if filepath.IsAbs(dir) { + return dir + } + + if baseDir == "" { + return dir + } + + return filepath.Join(baseDir, dir) +} + +func executableDirectory() (string, error) { + ex, err := os.Executable() + if err != nil { + return "", err + } + + return filepath.Dir(ex), nil +} + +func configuredServiceLogDirectory(dir string) (string, error) { + if dir == "" { + return "", nil + } + + exeDir, err := executableDirectory() + if err != nil { + return "", err + } + + return resolveLogDirectory(dir, exeDir), nil +} diff --git a/service/internal/servicehost/log_directory_test.go b/service/internal/servicehost/log_directory_test.go new file mode 100644 index 0000000..0c3c16f --- /dev/null +++ b/service/internal/servicehost/log_directory_test.go @@ -0,0 +1,20 @@ +package servicehost + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveLogDirectory(t *testing.T) { + t.Parallel() + + baseDir := filepath.Join(t.TempDir(), "OliveTin") + absoluteDir := t.TempDir() + + assert.Equal(t, "", resolveLogDirectory("", baseDir)) + assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir)) + assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir)) + assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", "")) +} diff --git a/service/internal/servicehost/servicehost_nonwin.go b/service/internal/servicehost/servicehost_nonwin.go index 8ecd407..d5fb296 100644 --- a/service/internal/servicehost/servicehost_nonwin.go +++ b/service/internal/servicehost/servicehost_nonwin.go @@ -7,7 +7,7 @@ import ( log "github.com/sirupsen/logrus" ) -func Start(mode string) { +func Start(_ string, _ string) { log.Debugf("servicehost nonwin") } diff --git a/service/internal/servicehost/servicehost_windows.go b/service/internal/servicehost/servicehost_windows.go index a653043..38fa5f0 100644 --- a/service/internal/servicehost/servicehost_windows.go +++ b/service/internal/servicehost/servicehost_windows.go @@ -50,10 +50,24 @@ func (m *otWindowsService) Execute(args []string, r <-chan svc.ChangeRequest, st } } -func setupLogging() { - logsDir := path.Join(GetConfigFilePath(), "logs") +func setupLogging(serviceLogDirectory string) { + logsDir, err := configuredServiceLogDirectory(serviceLogDirectory) + if err != nil { + log.Warnf("Failed to resolve serviceLogs.directory relative to executable: %v", err) + } - os.MkdirAll(logsDir, 0755) + if logsDir == "" { + logsDir = path.Join(GetConfigFilePath(), "logs") + } + + openServiceLogFile(logsDir) +} + +func openServiceLogFile(logsDir string) { + if err := os.MkdirAll(logsDir, 0755); err != nil { + log.Errorf("Failed to create logs directory %v: %v", logsDir, err) + return + } timestamp := time.Now().Format("2006-01-02_15-04-05") @@ -64,12 +78,13 @@ func setupLogging() { f, err := os.Create(filename) if err != nil { - log.Infof("Failed to open log file: %v", err) - } else { - log.Infof("Switching to log file: %v", f.Name()) - log.SetOutput(f) - log.Infof("Opened log file: %v", f.Name()) + log.Errorf("Failed to open log file: %v", err) + return } + + log.Infof("Switching to log file: %v", f.Name()) + log.SetOutput(f) + log.Infof("Opened log file: %v", f.Name()) } func GetConfigFilePath() string { @@ -132,8 +147,8 @@ func startServiceHandler(mode string) { } -func Start(mode string) { - setupLogging() +func Start(mode string, serviceLogDirectory string) { + setupLogging(serviceLogDirectory) go startServiceHandler(mode) } diff --git a/service/internal/tpl/templates.go b/service/internal/tpl/templates.go index 580b907..e2e23d3 100644 --- a/service/internal/tpl/templates.go +++ b/service/internal/tpl/templates.go @@ -1,6 +1,7 @@ package tpl import ( + "encoding/json" "fmt" "regexp" "strings" @@ -12,8 +13,22 @@ import ( log "github.com/sirupsen/logrus" ) +func jsonFunc(v any) (string, error) { + if v == nil { + return "null", nil + } + data, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(data), nil +} + +// Root template (funcs/options). parseTemplate clones before Parse — text/template +// must not receive concurrent Parse calls on the same instance. var tpl = template.New("tpl"). - Option("missingkey=error") + Option("missingkey=error"). + Funcs(template.FuncMap{"Json": jsonFunc}) type olivetinInfo struct { Build *installationinfo.BuildInfo @@ -166,7 +181,12 @@ func checkMissingArgumentError(err error) (bool, string) { } func parseTemplate(source string, data any) (string, error) { - t, err := tpl.Parse(source) + clone, err := tpl.Clone() + if err != nil { + return "", err + } + + t, err := clone.Parse(source) if err != nil { return "", err diff --git a/service/internal/tpl/templates_test.go b/service/internal/tpl/templates_test.go new file mode 100644 index 0000000..b49fe33 --- /dev/null +++ b/service/internal/tpl/templates_test.go @@ -0,0 +1,86 @@ +package tpl + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/OliveTin/OliveTin/internal/entities" + "github.com/stretchr/testify/assert" +) + +func TestParseTemplateWithActionContext_Json(t *testing.T) { + tests := []struct { + name string + source string + ent *entities.Entity + args map[string]string + expectedOutput string + expectError bool + checkJsonOnly bool + }{ + { + name: "Arguments piped to Json", + source: `echo {{ .Arguments | Json }}`, + ent: nil, + args: map[string]string{"value": "true", "ot_username": "alice"}, + expectedOutput: `echo `, + expectError: false, + checkJsonOnly: true, + }, + { + name: "CurrentEntity field piped to Json", + source: `curl -d {{ .CurrentEntity.foo.bar | Json }}`, + ent: &entities.Entity{Data: map[string]any{"foo": map[string]any{"bar": "baz"}}}, + args: nil, + expectedOutput: `curl -d "baz"`, + expectError: false, + }, + { + name: "CurrentEntity nested object piped to Json", + source: `curl --json -d {{ .CurrentEntity.payload | Json }}`, + ent: &entities.Entity{Data: map[string]any{"payload": map[string]any{"on": true}}}, + args: nil, + expectedOutput: `curl --json -d {"on":true}`, + expectError: false, + }, + { + name: "Single argument value as Json", + source: `echo {{ .Arguments.value | Json }}`, + ent: nil, + args: map[string]string{"value": "hello"}, + expectedOutput: `echo "hello"`, + expectError: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := ParseTemplateWithActionContext(tt.source, tt.ent, tt.args) + if tt.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + if tt.checkJsonOnly { + assertJsonOutput(t, output, tt.expectedOutput, tt.args) + } else { + assert.Equal(t, tt.expectedOutput, output) + } + }) + } +} + +func assertJsonOutput(t *testing.T, output, expectedPrefix string, args map[string]string) { + t.Helper() + prefix := strings.TrimSuffix(expectedPrefix, " ") + assert.True(t, strings.HasPrefix(output, prefix), "output %q should start with %q", output, prefix) + jsonPart := strings.TrimPrefix(output, prefix) + jsonPart = strings.TrimSpace(jsonPart) + var decoded map[string]string + err := json.Unmarshal([]byte(jsonPart), &decoded) + assert.NoError(t, err) + for k, v := range args { + assert.Equal(t, v, decoded[k], "decoded JSON should contain %s=%s", k, v) + } + assert.Len(t, decoded, len(args)) +} diff --git a/service/internal/webhooks/handler.go b/service/internal/webhooks/handler.go index a530690..df56046 100644 --- a/service/internal/webhooks/handler.go +++ b/service/internal/webhooks/handler.go @@ -137,11 +137,20 @@ func (h *WebhookHandler) processWebhook(actionConfig ActionWebhookConfig, r *htt return false } - h.executeAction(actionConfig.Action, args) + justification, err := matcher.ExtractJustification() + if err != nil { + log.WithFields(log.Fields{ + "actionTitle": actionConfig.Action.Title, + "error": err, + }).Warnf("Failed to extract webhook justification") + return false + } + + h.executeAction(actionConfig.Action, args, justification) return true } -func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string) { +func (h *WebhookHandler) executeAction(action *config.Action, args map[string]string, justification string) { binding := h.executor.FindBindingWithNoEntity(action) if binding == nil { log.WithFields(log.Fields{ @@ -150,13 +159,29 @@ func (h *WebhookHandler) executeAction(action *config.Action, args map[string]st return } + definedArgs := filterToDefinedArguments(args, action) req := &executor.ExecutionRequest{ Binding: binding, Cfg: h.cfg, Tags: []string{"webhook"}, - Arguments: args, + Arguments: definedArgs, + Justification: justification, AuthenticatedUser: auth.UserFromSystem(h.cfg, "webhook"), } h.executor.ExecRequest(req) } + +func filterToDefinedArguments(args map[string]string, action *config.Action) map[string]string { + definedNames := make(map[string]struct{}) + for _, arg := range action.Arguments { + definedNames[arg.Name] = struct{}{} + } + filtered := make(map[string]string) + for k, v := range args { + if _, ok := definedNames[k]; ok { + filtered[k] = v + } + } + return filtered +} diff --git a/service/internal/webhooks/handler_test.go b/service/internal/webhooks/handler_test.go new file mode 100644 index 0000000..30831aa --- /dev/null +++ b/service/internal/webhooks/handler_test.go @@ -0,0 +1,32 @@ +package webhooks + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + config "github.com/OliveTin/OliveTin/internal/config" +) + +func TestFilterToDefinedArguments(t *testing.T) { + action := &config.Action{ + Arguments: []config.ActionArgument{ + {Name: "repo", Type: "ascii_identifier"}, + {Name: "branch", Type: "ascii_identifier"}, + }, + } + args := map[string]string{ + "repo": "my-repo", + "branch": "main", + "webhook_path": "/deploy/prod", + "webhook_header_x_custom": "malicious", + } + + filtered := filterToDefinedArguments(args, action) + + assert.Equal(t, "my-repo", filtered["repo"]) + assert.Equal(t, "main", filtered["branch"]) + assert.Empty(t, filtered["webhook_path"]) + assert.Empty(t, filtered["webhook_header_x_custom"]) + assert.Len(t, filtered, 2) +} diff --git a/service/internal/webhooks/matcher.go b/service/internal/webhooks/matcher.go index 3d30d5e..94c64a8 100644 --- a/service/internal/webhooks/matcher.go +++ b/service/internal/webhooks/matcher.go @@ -138,6 +138,19 @@ func (m *WebhookMatcher) compareValues(actual, expected string) bool { return actual == expected } +func (m *WebhookMatcher) ExtractJustification() (string, error) { + if m.config.Justification == "" { + return "", nil + } + + matcher, err := NewJSONMatcher(m.bodyBytes) + if err != nil { + return "", err + } + + return matcher.ExtractValue(m.config.Justification) +} + func (m *WebhookMatcher) ExtractArguments() (map[string]string, error) { matcher, err := NewJSONMatcher(m.bodyBytes) if err != nil { diff --git a/service/internal/webhooks/matcher_justification_test.go b/service/internal/webhooks/matcher_justification_test.go new file mode 100644 index 0000000..970f0a8 --- /dev/null +++ b/service/internal/webhooks/matcher_justification_test.go @@ -0,0 +1,36 @@ +package webhooks + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + config "github.com/OliveTin/OliveTin/internal/config" +) + +func TestExtractJustificationFromWebhookBody(t *testing.T) { + body := []byte(`{"message":"deploy production","repo":"my-app"}`) + req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil) + require.NoError(t, err) + + matcher := NewWebhookMatcher(config.WebhookConfig{ + Justification: "$.message", + }, req, body) + + value, err := matcher.ExtractJustification() + require.NoError(t, err) + assert.Equal(t, "deploy production", value) +} + +func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil) + require.NoError(t, err) + + matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`)) + + value, err := matcher.ExtractJustification() + require.NoError(t, err) + assert.Empty(t, value) +} diff --git a/service/main.go b/service/main.go index eeb2ca8..8700692 100644 --- a/service/main.go +++ b/service/main.go @@ -7,6 +7,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/OliveTin/OliveTin/internal/api" "github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/entities" "github.com/OliveTin/OliveTin/internal/executor" @@ -245,7 +246,7 @@ func warnIfPuidGuid() { } func main() { - servicehost.Start(cfg.ServiceHostMode) + servicehost.Start(cfg.ServiceHostMode, cfg.ServiceLogs.Directory) log.WithFields(log.Fields{ "configDir": cfg.GetDir(), @@ -259,12 +260,14 @@ func main() { executor.LoadLogsFromDisk() + api.RegisterExecutorListener(executor) + entities.AddListener(executor.RebuildActionMap) + go onstartup.Execute(cfg, executor) go oncron.Schedule(cfg, executor) go onfileindir.WatchFilesInDirectory(cfg, executor) go oncalendarfile.Schedule(cfg, executor) - entities.AddListener(executor.RebuildActionMap) go entities.SetupEntityFileWatchers(cfg) go updatecheck.StartUpdateChecker(cfg) diff --git a/specs/action-group-concurrency.md b/specs/action-group-concurrency.md new file mode 100644 index 0000000..e45cfbe --- /dev/null +++ b/specs/action-group-concurrency.md @@ -0,0 +1,19 @@ +# Action group concurrency + +Actions may belong to one or more named groups. Each group may define a maximum number of concurrent executions shared across all actions in that group. + +When a user or trigger starts an action that belongs to a group, OliveTin counts how many executions for that group are currently active. Active means the execution has been requested but not yet finished, and is not waiting in a queue. + +If every configured group for that action has spare capacity, the execution proceeds through the normal execution pipeline. + +If any configured group is at capacity, the new execution is queued instead of rejected. The request receives a tracking identifier immediately. The log entry shows a queued status until the execution actually starts. + +Queued executions run in first-in-first-out order per OliveTin instance. When an active execution in a group finishes, OliveTin attempts to start the oldest queued execution that belongs to that group, provided all groups for that queued action now have spare capacity. + +An action may belong to multiple groups. In that case, all group limits must be satisfied before the action starts or leaves the queue. + +Per-action concurrency limits apply only to executions of the same action binding. When a per-action limit is exceeded, the request is blocked immediately and is not queued. + +Action group concurrency limits do not survive a process restart. Queued executions that have not started are discarded when OliveTin stops. + +If an action references a group name that is not defined in configuration, OliveTin logs a warning and does not apply a group limit for that name. diff --git a/specs/dashboard-component-ordering.md b/specs/dashboard-component-ordering.md new file mode 100644 index 0000000..6723fb5 --- /dev/null +++ b/specs/dashboard-component-ordering.md @@ -0,0 +1,52 @@ +# Spec: Dashboard component ordering + +This spec describes how dashboard components (fieldsets, entity fieldsets, actions, and other elements) are ordered in OliveTin. It documents the current behaviour so that it can be reasoned about and kept consistent. + +--- + +## 1. Implementation + +### 1.1 Two ways dashboards are built + +Dashboards are built in two ways: + +- **Default dashboard:** Used when there is no dashboard configuration. A single fieldset titled "Actions" is created and filled with actions that are not already on a configured dashboard. +- **Config dashboard:** Built from the dashboard configuration (e.g. under dashboards or dashboards.d). The structure is derived by walking the config tree, which produces a mix of fieldsets and a special root fieldset titled "Actions" that holds any loose items. + +Ordering rules differ slightly between these two cases. + +### 1.2 Top-level dashboard contents (config dashboards) + +**Fieldsets without entities:** Fieldsets that are not tied to an entity type appear at the top level in **config order**. Their position in the dashboard matches the order in which they are defined in the config. + +**Other top-level components:** All other top-level components (including the root "Actions" fieldset and entity fieldset groups) are **sorted** before being shown. Sort order: + +1. If a component has no linked action, it is ordered by its title (alphabetically). +2. Otherwise, components are ordered first by the action's order value (lower values first). +3. If order values are equal, components are ordered by entity key: if both keys are whole numbers they are compared numerically; otherwise they are compared alphabetically. +4. If still equal, components are ordered by the action's title (alphabetically). + +The root "Actions" fieldset is the single fieldset created by the build to hold loose items; it is identified by reference (not by position). When present it is added last to the list, then the sort is applied among that fieldset and entity-related components. So that fieldset can appear anywhere among those according to the rules above. When there are no loose items the root is not present, and the last component in the list is not treated as the root—so a fieldset without entities in the last position keeps config order. Regular fieldsets stay in config order and are not reordered. + +### 1.3 Entity fieldsets (order of fieldsets per entity type) + +When a fieldset in the config is tied to an entity type (e.g. "Server" or "Project"), one fieldset is built per entity instance. Those fieldsets are shown in **entity key order**. + +**Entity key order:** + +- If both keys are whole numbers: **numeric** order (e.g. 2 before 10). +- Otherwise: **alphabetical** (lexicographic) order. + +So the order of entity fieldsets (e.g. one per server, one per project) is determined by this entity key order, not by config or insertion order. + +### 1.4 Contents inside fieldsets + +**Default dashboard:** The single "Actions" fieldset's contents are sorted. The same rules as for top-level components apply: order value first, then entity key (numeric then alphabetical), then action title. + +**Config dashboards:** For all fieldsets (the root "Actions" fieldset, entity fieldsets, and regular fieldsets), the contents are **not** sorted. They keep the order from the config: + +- **Root "Actions" fieldset:** Items appear in the order they are listed in the config (loose items that are not inside a fieldset). +- **Entity fieldset contents:** The order comes from the template's contents in the config. +- **Regular (non-entity) fieldset contents:** The order comes from the config, including for nested structure. + +So within any config-defined fieldset, the order of actions and other child components is the **config order**. diff --git a/var/macos/app.olivetin.olivetin.plist b/var/macos/app.olivetin.olivetin.plist new file mode 100644 index 0000000..0629d33 --- /dev/null +++ b/var/macos/app.olivetin.olivetin.plist @@ -0,0 +1,48 @@ + + + + + + Label + app.olivetin.olivetin + + + ProgramArguments + + /usr/local/bin/OliveTin + -configdir + /Users/YOUR_USER/etc/OliveTin + + + + KeepAlive + + + + RunAtLoad + + + + StandardOutPath + /Users/YOUR_USER/Library/Logs/olivetin.log + StandardErrorPath + /Users/YOUR_USER/Library/Logs/olivetin.log + + diff --git a/var/macos/config.yaml b/var/macos/config.yaml new file mode 100644 index 0000000..ea89835 --- /dev/null +++ b/var/macos/config.yaml @@ -0,0 +1,263 @@ +# ============================================================================= +# OliveTin example configuration — macOS (Apple Silicon & Intel) +# ============================================================================= +# +# This is a macOS-flavoured version of the stock `example.config.yaml`. Every +# action that differs from the Linux example carries a `# Linux equivalent:` +# comment so you can see exactly what was changed and why. +# +# All commands here are tested against macOS with /bin/zsh (the default login +# shell since macOS Catalina) and also work under /bin/bash. OliveTin runs the +# `shell:` string with `sh -c` by default, so these are written to be portable +# POSIX/zsh/bash one-liners. +# +# To use this file, copy it next to the OliveTin binary as `config.yaml`: +# cp ./var/macos/config.yaml config.yaml +# ./OliveTin +# +# Docs: https://docs.olivetin.app/ +# ============================================================================= + +# The built-in micro proxy hosts the WebUI and REST API on a single port. +# Listen on all addresses, port 1337. Open http://localhost:1337 after start. +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 + +# Choose from INFO (default), WARN and DEBUG. +# Docs: https://docs.olivetin.app/advanced_configuration/logs.html +logLevel: "INFO" + +# Actions are commands that OliveTin executes, normally shown as buttons. +# Docs: https://docs.olivetin.app/action_execution/create_your_first.html +actions: + # The simplest possible action: run a command, flash the button for status. + # `ping` works identically on macOS and Linux. + - title: Ping the Internet + shell: ping -c 3 1.1.1.1 + icon: ping + popupOnStart: execution-dialog-stdout-only + execOnStartup: true + + # Show just the command output in a popup. + # + # Linux equivalent: shell: df -h /media + # macOS has no /media mountpoint. The root volume is `/`; external/USB disks + # mount under /Volumes. `df -h /` shows the boot disk; drop the path to list + # every mounted volume. + - title: Check disk space + icon: disk + shell: df -h / + popupOnStart: execution-dialog-stdout-only + + # Show a fuller dialog with details about the command that ran. + # + # Linux equivalent: shell: dmesg | tail + # macOS `dmesg` requires root and is rarely useful. The unified logging + # system is the macOS way to read kernel/system messages. This shows the + # last 2 minutes of high-level system log entries. + - title: Recent system log + shell: log show --last 2m --style compact | tail -n 40 + icon: logs + popupOnStart: execution-dialog + + # A mini button that links to the logs, with rate limiting and an hourly cron. + # `date` is identical across platforms. + - title: date + shell: date + id: date + timeout: 6 + icon: clock + popupOnStart: execution-button + maxRate: + - limit: 3 + duration: 1m + execOnCron: + - "@hourly" + + # --------------------------------------------------------------------------- + # macOS-native actions (no Linux equivalent — these showcase the platform) + # --------------------------------------------------------------------------- + + # Send a real macOS Notification Center banner via AppleScript. + - title: Send a notification + icon: '🔔' # bell + shell: osascript -e 'display notification "Triggered from OliveTin" with title "OliveTin" sound name "Glass"' + popupOnStart: execution-button + + # Stop the Mac from sleeping for 1 hour (handy during long jobs/downloads). + # `caffeinate` is a built-in macOS utility. `-t` is seconds. + - title: Keep awake for 1 hour + icon: '☕' # hot beverage + shell: caffeinate -d -i -t 3600 & + popupOnStart: execution-button + + # Put the displays to sleep immediately (the Mac stays running). + - title: Sleep the displays + icon: '💤' # zzz + shell: pmset displaysleepnow + popupOnStart: execution-button + + # Battery / power summary using the built-in `pmset`. + - title: Power & battery status + icon: '🔋' # battery + shell: pmset -g batt + popupOnStart: execution-dialog-stdout-only + + # --------------------------------------------------------------------------- + + # Prompt the user for input with `arguments`. `ping` again works as-is. + # Docs: https://docs.olivetin.app/action_examples/ping.html + - title: Ping host + id: ping_host + shell: ping {{ host }} -c {{ count }} + icon: ping + timeout: 100 + popupOnStart: execution-dialog-stdout-only + arguments: + - name: host + title: Host + type: ascii_identifier + default: example.com + description: The host that you want to ping + + - name: count + title: Count + type: int + default: 3 + description: How many times do you want to ping? + + # OliveTin can control Docker containers — `docker` is just a CLI app. + # On macOS this requires Docker Desktop (or colima/podman) to be installed + # and running. The command itself is identical to Linux. + # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html + - title: Restart Docker Container + icon: restart + shell: docker restart {{ container }} + arguments: + - name: container + title: Container name + choices: + - value: plex + - value: traefik + - value: grafana + + # The special `confirmation` argument guards against accidental clicks. + # Docs: https://docs.olivetin.app/args/input_confirmation.html + # + # Linux equivalent: shell: rm -rf /opt/oldBackups/ + # Using a path under the user's home is more natural on macOS. + - title: Delete old backups + icon: ashtonished + justification: true + shell: rm -rf "$HOME/Backups/old/" + arguments: + - name: confirm + type: confirmation + title: Are you sure?! + + # Run your own scripts, not just OS commands. `maxConcurrent` prevents + # parallel runs; `timeout` kills a command that runs too long. + # + # Linux equivalent: shell: /opt/backupScript.sh + # macOS convention is to keep personal scripts under your home directory. + - title: Run backup script + shell: "$HOME/bin/backupScript.sh" + shellAfterCompleted: "osascript -e 'display notification \"Backup finished with code {{ exitCode }}\" with title \"OliveTin\"'" + maxConcurrent: 1 + timeout: 10 + icon: backup + popupOnStart: execution-dialog + + # Download themes using a script bundled with OliveTin. You still need to set + # `themeName` in this config to actually use the theme. + # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html + - title: Get OliveTin Theme + exec: + - "olivetin-get-theme" + - "{{ themeGitRepo }}" + - "{{ themeFolderName }}" + icon: theme + arguments: + - name: themeGitRepo + title: Theme's Git Repository + description: Find new themes at https://olivetin.app/themes + type: url + + - name: themeFolderName + title: Theme's Folder Name + type: ascii_identifier + + # Run actions on other servers over SSH. macOS ships with an OpenSSH client, + # so this works out of the box. The helper below is optional. + # Docs: https://docs.olivetin.app/action_examples/ssh-easy.html + - title: "Setup easy SSH" + icon: ssh + shell: olivetin-setup-easy-ssh + popupOnStart: execution-dialog + +# Entities let you generate actions dynamically from "things" (servers, +# containers, VMs) loaded from files on disk. +# Docs: https://docs.olivetin.app/entities/intro.html +# +# entities: +# - file: entities/servers.yaml +# name: server + +# Dashboards organise actions into folders and fieldsets. +# Docs: https://docs.olivetin.app/dashboards/intro.html +# +# dashboards: +# - title: My Mac +# contents: +# - title: Power & battery status +# - title: Sleep the displays + +# ============================================================================= +# Security - Authentication +# ============================================================================= + +# If "true", users must log in before doing anything. +authRequireGuestsToLogin: false + +# The simplest auth: define users/passwords in this config. OliveTin also +# supports header-based auth, OAuth2 and JWT (documented separately). +# Docs: https://docs.olivetin.app/security/local.html +# +# Generating an argon2id hash on macOS: +# brew install argon2 +# echo -n 'yourPassword' | argon2 "$(openssl rand -base64 16)" -id -e +# (Linux equivalent typically uses the distro's `argon2` package directly.) +authLocalUsers: + enabled: true +# users: +# - username: alice +# usergroup: admins +# password: "$argon2id$v=19$m=65536,t=4,p=2$puyxA0s555TSFx7hnFLCXA$PyhLGpZtvpMMvc2DgMWkM8OJMKO55euwV5gm//1iwx4" + +# ============================================================================= +# Security - Access Control +# ============================================================================= + +# Policies affect the whole app (eg: ability to view the log list). +# Docs: https://docs.olivetin.app/security/acl.html +defaultPolicy: + showDiagnostics: true + showLogList: true + +# Permissions affect individual actions. +defaultPermissions: + view: true + exec: true + logs: true + +# ACLs match policy/permissions to users. +accessControlLists: + - name: admin_acl + matchUsergroups: ["admins"] + policy: + showDiagnostics: true + permissions: + view: true + exec: true + logs: true + +# OliveTin has many more options not shown here. See docs.olivetin.app. diff --git a/var/macos/install.md b/var/macos/install.md new file mode 100644 index 0000000..6c8ad6e --- /dev/null +++ b/var/macos/install.md @@ -0,0 +1,383 @@ +# Installing OliveTin on macOS + +> **Draft** — local Markdown draft kept in sync with the AsciiDoc docs at +> and +> +> (`docs/modules/ROOT/pages/install/macos.adoc` and `macos_service.adoc`). + +OliveTin runs natively on macOS on both **Apple Silicon (M1/M2/M3/M4)** and +**Intel** Macs. It is a single self-contained binary written in Go — there is no +installer and no background dependencies to install. + +--- + +## 1. Choose the right download + +macOS builds are published on the +[GitHub releases page](https://github.com/OliveTin/OliveTin/releases). Pick the +archive that matches your Mac's processor: + +| Your Mac | Archive | +|---|---| +| Apple Silicon (M-series) | `OliveTin-darwin-arm64.tar.gz` | +| Intel | `OliveTin-darwin-amd64.tar.gz` | + +Not sure which you have? Run this in Terminal: + +```sh +uname -m +``` + +`arm64` → Apple Silicon, `x86_64` → Intel. + +> If you download the wrong architecture, macOS will refuse to run it with a +> "Bad CPU type in executable" error. + +--- + +## 2. Extract and place the binary + +```sh +# Move to your Downloads folder (adjust if needed) +cd ~/Downloads + +# Extract — replace arm64 with amd64 on Intel +tar -xzf OliveTin-darwin-arm64.tar.gz +cd OliveTin-darwin-arm64 +``` + +For a quick try-out you can run it straight from this folder. To install it +properly, see step 6 — you can install it **as your own user (no root)** or +**system-wide**. + +--- + +## 3. Clear the Gatekeeper quarantine + +Because the binary is downloaded from the internet and is **not notarized by +Apple**, macOS Gatekeeper will block the first run with a message like +*"OliveTin can't be opened because Apple cannot check it for malicious +software."* + +Remove the quarantine attribute so it will run: + +```sh +xattr -dr com.apple.quarantine ./OliveTin +``` + +Alternatively, the first time only, you can right-click the binary in Finder → +**Open**, or approve it under **System Settings → Privacy & Security**. + +--- + +## 4. Create a configuration file + +OliveTin looks for a file named `config.yaml` in its **config directory**, which +defaults to the current directory (`.`). You can point elsewhere with +`-configdir /path/to/dir`. + +A minimal `config.yaml` to confirm everything works: + +```yaml +listenAddressSingleHTTPFrontend: 0.0.0.0:1337 +logLevel: "INFO" + +actions: + - title: Hello macOS + icon: terminal + shell: echo "Hello from $(scutil --get ComputerName)!" + popupOnStart: execution-dialog-stdout-only +``` + +For a fuller, macOS-tuned starting point — with working examples for +notifications (`osascript`), `caffeinate`, `pmset`, disk usage, the unified +system log, and Docker — see the **`config.macos.yaml`** that ships alongside +this guide. Copy it in place with: + +```sh +cp config.macos.yaml config.yaml +``` + +--- + +## 5. Run OliveTin + +From the folder that contains both `OliveTin` and `config.yaml`: + +```sh +./OliveTin +``` + +Then open the web interface at: + +```text +http://localhost:1337 +``` + +(or `http://:1337` from another device on your network). + +Press **Ctrl-C** in the Terminal to stop it. + +--- + +## 6. Run OliveTin as a background service (launchd) + +On Linux, OliveTin is managed by **systemd**. The macOS equivalent is +**launchd**. launchd offers two ways to run a background service, and which one +you pick decides whether you need root: + +* **LaunchAgent (local user)** — runs as *your* user and starts when you log in. + **No `sudo` required**, and everything lives under your home folder. Best for a + desktop Mac. See [Local user installation](#local-user-installation-no-root). +* **LaunchDaemon (system-wide)** — runs as `root` and starts at boot, before any + user logs in. Requires `sudo`. Best for a headless, always-on Mac. See + [System-wide installation](#system-wide-installation-requires-root). + +You only need to follow **one** of the two sections below. + +### Local user installation (no root) + +Everything — the binary, configuration, the `var` data folder, and the `webui` +folder — is kept together under `~/Library/Application Support/OliveTin`, so you +never need `sudo`. + +**Install the files** (run from the extracted archive directory): + +```sh +# Create the application folder and a place for logs +mkdir -p ~/Library/Application\ Support/OliveTin/var +mkdir -p ~/Library/Logs/OliveTin + +# Copy in the binary, your config, and the bundled web UI +cp OliveTin ~/Library/Application\ Support/OliveTin/ +cp config.yaml ~/Library/Application\ Support/OliveTin/ +cp -R webui ~/Library/Application\ Support/OliveTin/ +``` + +This gives you the following layout, all owned by your user: + +``` +~/Library/Application Support/OliveTin/ +├── OliveTin # the binary +├── config.yaml # your configuration +├── webui/ # the web interface assets (shipped in the archive) +└── var/ # runtime data OliveTin writes (logs, etc.) + +~/Library/Logs/OliveTin/olivetin.log # service stdout/stderr +``` + +**Create the service definition.** Create a file named +`app.olivetin.olivetin.plist` with the contents below. + +> **Important:** launchd does *not* expand `~`, so the paths must be absolute. +> Replace `YOUR_USERNAME` with the output of `whoami` in every path. + +```xml + + + + + Label + app.olivetin.olivetin + + ProgramArguments + + /Users/YOUR_USERNAME/Library/Application Support/OliveTin/OliveTin + -configdir + /Users/YOUR_USERNAME/Library/Application Support/OliveTin + + + WorkingDirectory + /Users/YOUR_USERNAME/Library/Application Support/OliveTin + + KeepAlive + + + RunAtLoad + + + StandardOutPath + /Users/YOUR_USERNAME/Library/Logs/OliveTin/olivetin.log + StandardErrorPath + /Users/YOUR_USERNAME/Library/Logs/OliveTin/olivetin.log + + +``` + +`WorkingDirectory` makes the relative `webui` and `var` folders resolve inside +the application folder, `KeepAlive` restarts OliveTin if it exits (like systemd's +`Restart=always`), and `RunAtLoad` starts it as soon as the service is loaded. + +**Register and start the service:** + +```sh +cp app.olivetin.olivetin.plist ~/Library/LaunchAgents/ +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +``` + +> `bootstrap`/`bootout` replace the deprecated `launchctl load`/`unload`. They +> take a *domain target*: `gui/$(id -u)` is your own per-user GUI domain +> (`id -u` is your numeric user ID). + +To stop and disable it: + +```sh +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +``` + +**Restart after a change.** After editing `config.yaml` or replacing the +binary, restart the service so the change takes effect. To restart in place: + +```sh +launchctl kickstart -k gui/$(id -u)/app.olivetin.olivetin +``` + +If you changed the *plist* itself, `kickstart` is not enough — boot the service +out and back in so launchd re-reads it (`bootstrap` errors if the service is +still loaded): + +```sh +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.olivetin.olivetin.plist +``` + +**Verify** — open . If the page does not load, check the +service log: + +```sh +tail -f ~/Library/Logs/OliveTin/olivetin.log +``` + +### System-wide installation (requires root) + +Use this for a headless or shared Mac that should start OliveTin at boot, before +anyone logs in. It installs the binary on the system `PATH` and runs as `root` +via a LaunchDaemon, so the commands use `sudo`. + +**Install the files:** + +```sh +sudo cp OliveTin /usr/local/bin/OliveTin + +sudo mkdir -p /usr/local/etc/OliveTin +sudo cp config.yaml /usr/local/etc/OliveTin/config.yaml +sudo cp -R webui /usr/local/etc/OliveTin/ +``` + +> OliveTin looks for `config.yaml` in the directory given by the `-configdir` +> flag, which defaults to the current directory. The service definition below +> passes `-configdir /usr/local/etc/OliveTin` explicitly, and sets +> `WorkingDirectory` so the `webui` and `var` folders resolve there. + +**Create the service definition.** Create a file named +`app.olivetin.olivetin.plist` with the following contents. Adjust the paths if +you installed OliveTin elsewhere. + +```xml + + + + + Label + app.olivetin.olivetin + + ProgramArguments + + /usr/local/bin/OliveTin + -configdir + /usr/local/etc/OliveTin + + + WorkingDirectory + /usr/local/etc/OliveTin + + KeepAlive + + + RunAtLoad + + + StandardOutPath + /usr/local/var/log/olivetin.log + StandardErrorPath + /usr/local/var/log/olivetin.log + + +``` + +`KeepAlive` restarts OliveTin if it exits (like systemd's `Restart=always`), and +`RunAtLoad` starts it as soon as the service is loaded. + +**Register and start the service:** + +```sh +sudo mkdir -p /usr/local/var/log +sudo cp app.olivetin.olivetin.plist /Library/LaunchDaemons/ +sudo chown root:wheel /Library/LaunchDaemons/app.olivetin.olivetin.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/app.olivetin.olivetin.plist +``` + +> `bootstrap`/`bootout` replace the deprecated `launchctl load`/`unload`. The +> domain target for a LaunchDaemon is `system`. + +To stop and disable it: + +```sh +sudo launchctl bootout system /Library/LaunchDaemons/app.olivetin.olivetin.plist +``` + +**Restart after a change.** After editing `config.yaml` or replacing the +binary, restart the service so the change takes effect. To restart in place: + +```sh +sudo launchctl kickstart -k system/app.olivetin.olivetin +``` + +If you changed the *plist* itself, `kickstart` is not enough — boot the service +out and back in so launchd re-reads it (`bootstrap` errors if the service is +still loaded): + +```sh +sudo launchctl bootout system /Library/LaunchDaemons/app.olivetin.olivetin.plist +sudo launchctl bootstrap system /Library/LaunchDaemons/app.olivetin.olivetin.plist +``` + +**Verify** — open . If the page does not load, check the +service log: + +```sh +tail -f /usr/local/var/log/olivetin.log +``` + +--- + +## Troubleshooting + +**"Bad CPU type in executable"** — you downloaded the wrong architecture. Get +the `arm64` build for Apple Silicon, `amd64` for Intel (see step 1). + +**Gatekeeper still blocks it** — re-run the `xattr -dr com.apple.quarantine` +command in step 3, or approve the app under **System Settings → Privacy & +Security**. + +**It runs but the page won't load** — check that nothing else is using port +1337 (`lsof -i :1337`), and that you're browsing to `http://` (not `https://`). + +**Reading the logs** + +* Running in Terminal: the log is printed directly to the window. +* Running under launchd as a local user: `tail -f ~/Library/Logs/OliveTin/olivetin.log` +* Running under launchd system-wide: `tail -f /usr/local/var/log/olivetin.log` +* You can raise detail by setting `logLevel: "DEBUG"` in `config.yaml`. + +**Still stuck?** Ask in the +[OliveTin Discord](https://discord.gg/jhYWWpNJ3v) or open an issue on +[GitHub](https://github.com/OliveTin/OliveTin/issues). + +--- + +## Next steps + +* [Create your first action](https://docs.olivetin.app/action_execution/create_your_first.html) +* [Configuration reference](https://docs.olivetin.app/) +* [Security & authentication](https://docs.olivetin.app/security/local.html) diff --git a/var/release-utils/unrelease.sh b/var/release-utils/unrelease.sh new file mode 100755 index 0000000..6d5243c --- /dev/null +++ b/var/release-utils/unrelease.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +RELEASE_NAME="${1:-}" +GHCR_IMAGE="ghcr.io/olivetin/olivetin" +DOCKERHUB_IMAGE="jamesread/olivetin" + +log() { + echo "[unrelease] $*" +} + +prompt_confirm() { + local prompt="$1" + local default="${2:-n}" + if [[ "$default" == "y" ]]; then + read -r -p "$prompt [Y/n] " reply + else + read -r -p "$prompt [y/N] " reply + fi + reply="${reply:-$default}" + case "$(echo "$reply" | tr '[:upper:]' '[:lower:]')" in + y|yes) return 0 ;; + *) return 1 ;; + esac +} + +if [[ -z "$RELEASE_NAME" ]]; then + echo "Usage: $0 " >&2 + echo "Example: $0 3000.10.0" >&2 + exit 1 +fi + +log "Release to remove: $RELEASE_NAME" +log "This will: 1) Delete GitHub release, 2) Delete GitHub tag, 3) Delete GHCR image tag, 4) Delete Docker Hub image tag" +echo + +# --- GitHub release --- +log "Step 1: Delete GitHub release '$RELEASE_NAME'" +if prompt_confirm "Delete GitHub release?" "n"; then + if err=$(gh release delete "$RELEASE_NAME" --yes 2>&1); then + log "Deleted GitHub release." + else + log "Failed to delete GitHub release:" >&2 + echo "$err" | sed 's/^/[unrelease] /' >&2 + fi +else + log "Skipped GitHub release." +fi +echo + +# --- GitHub tag --- +log "Step 2: Delete GitHub tag '$RELEASE_NAME'" +if prompt_confirm "Delete GitHub tag?" "n"; then + repo=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null) || repo="olivetin/olivetin" + if err=$(gh api -X DELETE "repos/$repo/git/refs/tags/$RELEASE_NAME" 2>&1); then + log "Deleted GitHub tag." + else + log "Failed to delete GitHub tag:" >&2 + echo "$err" | sed 's/^/[unrelease] /' >&2 + fi +else + log "Skipped GitHub tag." +fi +echo + +# --- GHCR --- +log "Step 3: Delete GHCR image tag $GHCR_IMAGE:$RELEASE_NAME" +if prompt_confirm "Delete GHCR container image version?" "n"; then + list_err=$(gh api "orgs/olivetin/packages/container/olivetin/versions" --jq ".[] | select(.metadata.container.tags[]? == \"$RELEASE_NAME\") | .id" 2>&1) || true + version_id=$(echo "$list_err" | head -1) + if [[ -z "$version_id" || ! "$version_id" =~ ^[0-9]+$ ]]; then + log "Could not resolve GHCR version for tag '$RELEASE_NAME' (need read:packages scope, or tag may not exist)." >&2 + if [[ "$list_err" == *"message"* ]]; then + msg=$(echo "$list_err" | sed -n 's/.*"message":"\([^"]*\)".*/\1/p' | head -1) + [[ -n "$msg" ]] && log " $msg" >&2 + fi + else + if err=$(gh api -X DELETE "orgs/olivetin/packages/container/olivetin/versions/$version_id" 2>&1); then + log "Deleted GHCR version (id: $version_id)." + else + log "Failed to delete GHCR version:" >&2 + echo "$err" | sed 's/^/[unrelease] /' >&2 + fi + fi +else + log "Skipped GHCR." +fi +echo + +# --- Docker Hub --- +log "Step 4: Delete Docker Hub image tag $DOCKERHUB_IMAGE:$RELEASE_NAME" +if prompt_confirm "Delete Docker Hub image tag? (requires DOCKERHUB_TOKEN)" "n"; then + if [[ -z "${DOCKERHUB_TOKEN:-}" ]]; then + log "DOCKERHUB_TOKEN is not set. Get a token from https://hub.docker.com/settings/security and run: DOCKERHUB_TOKEN=xxx $0 $RELEASE_NAME" >&2 + log "Skipped Docker Hub." + else + status=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ + -H "Authorization: Bearer $DOCKERHUB_TOKEN" \ + "https://hub.docker.com/v2/repositories/$DOCKERHUB_IMAGE/tags/$RELEASE_NAME/") + if [[ "$status" == "204" ]]; then + log "Deleted Docker Hub tag." + else + log "Docker Hub delete returned HTTP $status (tag may not exist or token invalid)." >&2 + fi + fi +else + log "Skipped Docker Hub." +fi + +log "Done."