From 1b4098c7e00e2e48e1b58a2633865c5ec823b1d6 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 12:52:25 +0200 Subject: [PATCH 1/2] chore: drop the playground, modernize CI and dependencies The SDK becomes a bare library on the way to the next API: - remove the Rails playground (test/dummy, bin/playground, lib/octaspace/playground), MockTransport, the Rails Engine and the Appraisal/Rails matrix; the Railtie stays (no Rails dependency) - require Ruby >= 3.3 (3.2 is EOL); CI tests 3.3, 3.4 and 4.0 - CI: actions pinned to commit SHAs, read-only token, concurrency, runs on main and next and on every PR, strict gem build, bundler-audit job - Dependabot for bundler and actions, targeting next, 3-day cooldown - faraday >= 2.14.3 (fixes published advisories); minitest 6, current webmock/standard/rake; railties and puma removed - docs/GIT_WORKFLOW_NEXT.md: how to work with the next branch --- .github/dependabot.yml | 25 ++ .github/workflows/ci.yml | 82 +++-- Appraisals | 11 - Gemfile | 18 +- README.md | 49 +-- bin/playground | 19 - docs/GIT_WORKFLOW_NEXT.md | 107 ++++++ gemfiles/rails_7_1.gemfile | 16 - gemfiles/rails_7_2.gemfile | 16 - gemfiles/rails_8_0.gemfile | 16 - lib/octaspace/engine.rb | 16 - lib/octaspace/playground/payload_presets.rb | 60 --- lib/octaspace/playground/payload_presets.yml | 58 --- lib/octaspace/playground/smoke_runner.rb | 204 ----------- lib/octaspace/transport/mock_transport.rb | 138 ------- octaspace.gemspec | 7 +- test/dummy/Rakefile | 4 - .../app/controllers/application_controller.rb | 341 ------------------ .../playground/accounts_controller.rb | 15 - .../controllers/playground/apps_controller.rb | 9 - .../controllers/playground/base_controller.rb | 78 ---- .../playground/dashboard_controller.rb | 17 - .../playground/diagnostics_controller.rb | 329 ----------------- .../playground/idle_jobs_controller.rb | 32 -- .../playground/network_controller.rb | 9 - .../playground/nodes_controller.rb | 22 -- .../playground/request_logs_controller.rb | 10 - .../playground/services_controller.rb | 12 - .../playground/sessions_controller.rb | 53 --- .../playground/settings_controller.rb | 41 --- .../app/views/layouts/application.html.erb | 180 --------- .../views/playground/accounts/show.html.erb | 66 ---- .../app/views/playground/apps/show.html.erb | 95 ----- .../views/playground/dashboard/show.html.erb | 111 ------ .../playground/diagnostics/show.html.erb | 137 ------- .../views/playground/idle_jobs/show.html.erb | 76 ---- .../views/playground/network/show.html.erb | 80 ---- .../app/views/playground/nodes/index.html.erb | 83 ----- .../app/views/playground/nodes/show.html.erb | 22 -- .../views/playground/services/show.html.erb | 134 ------- .../views/playground/sessions/index.html.erb | 138 ------- .../playground/shared/_error_card.html.erb | 28 -- .../playground/shared/_json_viewer.html.erb | 8 - .../shared/_node_detail_panel.html.erb | 58 --- .../shared/_resource_panel.html.erb | 52 --- .../shared/_sessions_table.html.erb | 102 ------ test/dummy/bin/rails | 6 - test/dummy/config.ru | 6 - test/dummy/config/application.rb | 22 -- test/dummy/config/boot.rb | 5 - test/dummy/config/environment.rb | 5 - test/dummy/config/initializers/octaspace.rb | 10 - test/dummy/config/routes.rb | 24 -- test/dummy/public/playground.css | 38 -- test/dummy/public/playground.js | 145 -------- test/dummy/public/service-worker.js | 1 - test/dummy_diagnostics_test.rb | 146 -------- test/dummy_resources_test.rb | 76 ---- test/dummy_sessions_test.rb | 55 --- test/octaspace/client_test.rb | 4 +- .../playground/payload_presets_test.rb | 33 -- .../octaspace/playground/smoke_runner_test.rb | 36 -- test/octaspace/resources/services_test.rb | 20 +- .../transport/mock_transport_test.rb | 39 -- test/test_helper.rb | 3 - 65 files changed, 212 insertions(+), 3646 deletions(-) create mode 100644 .github/dependabot.yml delete mode 100644 Appraisals delete mode 100755 bin/playground create mode 100644 docs/GIT_WORKFLOW_NEXT.md delete mode 100644 gemfiles/rails_7_1.gemfile delete mode 100644 gemfiles/rails_7_2.gemfile delete mode 100644 gemfiles/rails_8_0.gemfile delete mode 100644 lib/octaspace/engine.rb delete mode 100644 lib/octaspace/playground/payload_presets.rb delete mode 100644 lib/octaspace/playground/payload_presets.yml delete mode 100644 lib/octaspace/playground/smoke_runner.rb delete mode 100644 lib/octaspace/transport/mock_transport.rb delete mode 100644 test/dummy/Rakefile delete mode 100644 test/dummy/app/controllers/application_controller.rb delete mode 100644 test/dummy/app/controllers/playground/accounts_controller.rb delete mode 100644 test/dummy/app/controllers/playground/apps_controller.rb delete mode 100644 test/dummy/app/controllers/playground/base_controller.rb delete mode 100644 test/dummy/app/controllers/playground/dashboard_controller.rb delete mode 100644 test/dummy/app/controllers/playground/diagnostics_controller.rb delete mode 100644 test/dummy/app/controllers/playground/idle_jobs_controller.rb delete mode 100644 test/dummy/app/controllers/playground/network_controller.rb delete mode 100644 test/dummy/app/controllers/playground/nodes_controller.rb delete mode 100644 test/dummy/app/controllers/playground/request_logs_controller.rb delete mode 100644 test/dummy/app/controllers/playground/services_controller.rb delete mode 100644 test/dummy/app/controllers/playground/sessions_controller.rb delete mode 100644 test/dummy/app/controllers/playground/settings_controller.rb delete mode 100644 test/dummy/app/views/layouts/application.html.erb delete mode 100644 test/dummy/app/views/playground/accounts/show.html.erb delete mode 100644 test/dummy/app/views/playground/apps/show.html.erb delete mode 100644 test/dummy/app/views/playground/dashboard/show.html.erb delete mode 100644 test/dummy/app/views/playground/diagnostics/show.html.erb delete mode 100644 test/dummy/app/views/playground/idle_jobs/show.html.erb delete mode 100644 test/dummy/app/views/playground/network/show.html.erb delete mode 100644 test/dummy/app/views/playground/nodes/index.html.erb delete mode 100644 test/dummy/app/views/playground/nodes/show.html.erb delete mode 100644 test/dummy/app/views/playground/services/show.html.erb delete mode 100644 test/dummy/app/views/playground/sessions/index.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_error_card.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_json_viewer.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_node_detail_panel.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_resource_panel.html.erb delete mode 100644 test/dummy/app/views/playground/shared/_sessions_table.html.erb delete mode 100755 test/dummy/bin/rails delete mode 100644 test/dummy/config.ru delete mode 100644 test/dummy/config/application.rb delete mode 100644 test/dummy/config/boot.rb delete mode 100644 test/dummy/config/environment.rb delete mode 100644 test/dummy/config/initializers/octaspace.rb delete mode 100644 test/dummy/config/routes.rb delete mode 100644 test/dummy/public/playground.css delete mode 100644 test/dummy/public/playground.js delete mode 100644 test/dummy/public/service-worker.js delete mode 100644 test/dummy_diagnostics_test.rb delete mode 100644 test/dummy_resources_test.rb delete mode 100644 test/dummy_sessions_test.rb delete mode 100644 test/octaspace/playground/payload_presets_test.rb delete mode 100644 test/octaspace/playground/smoke_runner_test.rb delete mode 100644 test/octaspace/transport/mock_transport_test.rb diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c3338ba --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +version: 2 + +# Development happens on `next` until the new API is released; switch +# target-branch back to `main` when `next` is merged. +updates: + - package-ecosystem: bundler + directory: / + target-branch: next + schedule: + interval: weekly + # Supply-chain delay: do not pick up a release in its first days. + cooldown: + default-days: 3 + groups: + dev-dependencies: + dependency-type: development + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + target-branch: next + schedule: + interval: weekly + cooldown: + default-days: 3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d7a138..9f64d7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,55 +2,81 @@ name: CI on: push: - branches: [main] + branches: [main, next] + # Every pull request, including stacked ones whose base is another feature branch. pull_request: workflow_call: +# Least privilege: jobs only read the repository. +permissions: + contents: read + +# A new push to the same PR or branch cancels the run still in progress. +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +# Actions are pinned to full commit SHAs (tag in the comment); Dependabot keeps them current. jobs: + test: + name: Test (Ruby ${{ matrix.ruby }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Ruby lines still maintained upstream; 3.3 is the minimum in the gemspec. + ruby: ["3.3", "3.4", "4.0"] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Test + run: bundle exec rake test + + - name: Build gem + run: gem build octaspace.gemspec --strict + lint: name: Lint (StandardRB) runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 with: - ruby-version: "3.4" + ruby-version: "4.0" bundler-cache: true - name: StandardRB run: bundle exec standardrb - test: - name: Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }} + security: + name: Dependency audit runs-on: ubuntu-latest - timeout-minutes: 20 - - strategy: - fail-fast: false - matrix: - ruby: - - "3.2" - - "3.3" - - "3.4" - gemfile: - - gemfiles/rails_7_1.gemfile - - gemfiles/rails_7_2.gemfile - - gemfiles/rails_8_0.gemfile - - env: - BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} - BUNDLE_PATH_RELATIVE_TO_CWD: true + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@de492cf36cbab201753d720ce4f4e336cd688c94 # v1.336.0 with: - ruby-version: ${{ matrix.ruby }} + ruby-version: "4.0" bundler-cache: true - - name: Test - run: bundle exec rake test + # Fails on any advisory for the resolved gems (fresh advisory database each run). + - name: bundler-audit + run: bundle exec bundle-audit check --update diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 01f262b..0000000 --- a/Appraisals +++ /dev/null @@ -1,11 +0,0 @@ -appraise "rails-7-1" do - gem "rails", "~> 7.1.0" -end - -appraise "rails-7-2" do - gem "rails", "~> 7.2.0" -end - -appraise "rails-8-0" do - gem "rails", "~> 8.0.0" -end diff --git a/Gemfile b/Gemfile index 61e9093..fa36391 100644 --- a/Gemfile +++ b/Gemfile @@ -5,16 +5,12 @@ source "https://rubygems.org" gemspec # Development & testing -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" # StandardRB linter -gem "rake", "~> 13.0" -gem "appraisal" +gem "minitest", "~> 6.0" +gem "webmock", "~> 3.34" +gem "standard", "~> 1.57" # StandardRB linter +gem "rake", "~> 13.3" +gem "bundler-audit", "~> 0.9", require: false # Optional persistent mode (needed for persistent_transport tests) -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" - -# Rails (for Railtie/Engine/dummy app tests) -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" +gem "faraday-net_http_persistent", "~> 2.3" +gem "connection_pool", ">= 2.4", "< 4" diff --git a/README.md b/README.md index 2b73517..bfd20ef 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ Official Ruby SDK for the [OctaSpace API](https://api.octa.space/api-docs). - **Typed error hierarchy** — 12 exception classes mapped from HTTP status codes - **`on_request` / `on_response` hooks** — for logging, tracing, APM - **Rails integration** — Railtie, shared client, graceful shutdown at_exit -- **Playground app** — `bin/playground` with live diagnostics page -- **Ruby ≥ 3.2**, Rails 7.1 / 7.2 / 8.0 +- **Ruby ≥ 3.3**; optional Railtie for Rails apps ## Installation @@ -258,25 +257,6 @@ node.id # => 123 Available types: `Node`, `Account`, `Balance`, `Session`. -## Playground App - -Interactive demo for manual testing against a real API key: - -```bash -OCTA_API_KEY=your_key bin/playground -# → http://localhost:3000 -``` - -Pages: - -| Route | Content | -|---|---| -| `/playground/account` | Profile + balance | -| `/playground/nodes` | Node list with state badges | -| `/playground/sessions` | Current + recent sessions, including live-format quirks | -| `/playground/services` | Marketplace catalogs for MR, Render, and VPN | -| `/playground/diagnostics` | Direct SDK method runner for contracts, payloads, transport mode, and pool stats | - ## Development ### Gem Development @@ -304,33 +284,6 @@ irb > client_auth.accounts.profile.data # Retrieving profile data (email, ID, etc.) ``` -### Running tests against multiple Rails versions - -```bash -bundle exec appraisal rails-7-1 rake test -bundle exec appraisal rails-7-2 rake test -bundle exec appraisal rails-8-0 rake test -``` - -### Dummy Application (Playground) - -The repository includes a Rails "Dummy" application for manual testing and UI prototyping. It is located in `test/dummy`. - -To run the dummy app: - -1. Ensure you have an API key: `export OCTA_API_KEY=your_key` -2. Run the playground script: `bin/playground` (starts Puma on port 3000) -3. Visit `http://localhost:3000/playground/diagnostics` - -You can also run it manually from the directory: - -```bash -cd test/dummy -bin/rails server -``` - -The dummy app is configured to use the local version of the gem. It is **not** included in the published gem package. - ## Packaging and Publishing See [PUBLISHING.md](PUBLISHING.md) for instructions on how to package and release new versions of the gem. diff --git a/bin/playground b/bin/playground deleted file mode 100755 index a797c4a..0000000 --- a/bin/playground +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Launch the dummy Rails playground app -# Run: bin/playground -# -# Set OCTA_API_KEY in your environment before starting: -# OCTA_API_KEY=your_key bin/playground - -dummy_path = File.expand_path("../test/dummy", __dir__) - -unless Dir.exist?(dummy_path) - warn "Dummy app not found at #{dummy_path}" - exit 1 -end - -Dir.chdir(dummy_path) do - exec "bin/rails server #{ARGV.join(" ")}" -end diff --git a/docs/GIT_WORKFLOW_NEXT.md b/docs/GIT_WORKFLOW_NEXT.md new file mode 100644 index 0000000..11a260d --- /dev/null +++ b/docs/GIT_WORKFLOW_NEXT.md @@ -0,0 +1,107 @@ +# Работа с веткой `next` + +`next` — ветка перехода SDK на новое API OctaSpace (стенд `https://api-dev.octa.space`). +Все правки под новое API идут PR-ами в `next`; слияние `next → main` — отдельным решением, +когда новое API выйдет в прод. `main` остаётся веткой текущего (legacy) SDK и хотфиксов. +Так же устроены Cube, go-sdk, js-sdk и octa-cli. + +На GitHub ветка по умолчанию — **`main`**. Поэтому PR, открытый без явной базы, +уходит в `main`, а не в `next`. + +## `main` и `next` — по шагам + +| Шаг | Работа с `main` | Работа с `next` | +|---|---|---| +| Обновить ветки | `git fetch origin` | `git fetch origin` — то же | +| Перейти на базовую ветку | `git switch main` | `git switch next` | +| Подтянуть свежий код | `git pull` | `git pull` — то же (`next` отслеживает `origin/next`) | +| Новая ветка задачи | `git switch -c feat/x` (с `main`) | `git switch -c feat/x origin/next` ← **главное отличие** | +| Коммит | `git add …` → `git commit` | то же | +| Первый пуш ветки | `git push -u origin feat/x` | то же (пушится ветка задачи, не `next`) | +| Подтянуть свежую базу | `git rebase origin/main` | `git rebase origin/next` | +| PR через `gh` | `gh pr create` | `gh pr create --base next` ← **обязательно** | +| PR на сайте | база `main` подставится сама | вручную сменить **base: main → next** | +| Диф ветки против базы | `git diff main...HEAD` | `git diff origin/next...HEAD` | +| После слияния PR | `git switch main && git pull` | `git switch next && git pull` | + +`git switch -c feat/x origin/next` создаёт ветку задачи прямо от удалённой `next`, +даже если сейчас вы стоите на `main` и локальной `next` у вас нет. Ветка сразу +отслеживает `origin/next`; первый `git push -u origin feat/x` переключает её на +собственную удалённую ветку. + +## Типовой цикл задачи + +```bash +git fetch origin +git switch -c feat/my-task origin/next # от свежей next, даже если сейчас стоите на main +# ... правки ... +bundle exec rake test && bundle exec standardrb +git add -A && git commit -m "…" +git push -u origin feat/my-task +gh pr create --base next # без --base PR откроется в main +``` + +Если пока вы работали, в `next` что-то влили — обновить ветку перед PR: + +```bash +git fetch origin +git rebase origin/next +git push --force-with-lease # после rebase обычный push откажет +``` + +## Зависимые PR (стек) + +Если задача B строится на ещё не влитой задаче A, ветку B создают от A, а PR открывают +с базой A: `gh pr create --base feat/a`. Тогда в PR видна только разница B. + +⚠️ **Не удаляйте ветку A при слиянии** (`gh pr merge --delete-branch` или кнопка +«Delete branch»), пока на неё смотрит PR B: GitHub **закроет** PR B, а не перенацелит его. +Сливать стек по одному PR, в таком порядке: + +```bash +gh pr merge --merge # без --delete-branch +gh pr edit --base next # перенацелить следующий PR +git push origin --delete feat/a # теперь ветку A можно удалить +``` + +Если PR B всё же закрылся: восстановить ветку A на её последнем коммите +(`git push origin :refs/heads/feat/a`), затем `gh pr reopen ` и +`gh pr edit --base next`. + +## Ошибки, которые легко сделать + +1. **PR открылся в `main`.** Забыли `--base next` или не сменили base на сайте — + в PR вся разница `next` против `main`. + Исправить: `gh pr edit --base next`. +2. **Ветка создана от `main`.** `git switch -c feat/x`, стоя на `main`, даёт ветку + от `main`, и PR в `next` притащит лишние коммиты. Поэтому базу всегда указывать + явно (`origin/next`), а не полагаться на текущую ветку. + Исправить: `git rebase --onto origin/next origin/main feat/x`. +3. **Прямой push в `next`.** Пушить только ветки задач; в `next` — только через PR, + как и в `main`. +4. **Хотфиксы в `main` не доходят до `next`.** Срочные исправления legacy-SDK идут + веткой от `main` с PR в `main`. Чтобы они не потерялись, `main` периодически + вливается в `next` отдельным PR: + + ```bash + git switch -c chore/sync-main-into-next origin/next + git merge origin/main + git push -u origin chore/sync-main-into-next + gh pr create --base next + ``` + +5. **Устаревшая локальная `next`.** Она обновляется только по `git pull`. Для новой + ветки и rebase надёжнее брать `origin/next` сразу после `git fetch`. + +## CI и релизы + +- CI (`.github/workflows/ci.yml`) запускается на push в `main` и `next` и на любой PR, включая PR в стеке. +- Dependabot открывает PR обновлений в `next` (`target-branch` в `.github/dependabot.yml`). + После слияния `next → main` вернуть `target-branch: main`. +- Гем из `next` в RubyGems не публикуется, пока новое API не выйдет в прод. + +## Если `next` — надолго + +Можно переключить ветку по умолчанию на GitHub (Settings → Branches) на `next`: +PR будут открываться в неё сами, и ошибка 1 исчезнет. Это меняет базу по умолчанию +для всей команды, поэтому решается командой, а не одним разработчиком. diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile deleted file mode 100644 index 7ce12c2..0000000 --- a/gemfiles/rails_7_1.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 7.1.0" - -gemspec path: "../" diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile deleted file mode 100644 index e494610..0000000 --- a/gemfiles/rails_7_2.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 7.2.0" - -gemspec path: "../" diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile deleted file mode 100644 index 67cd0c4..0000000 --- a/gemfiles/rails_8_0.gemfile +++ /dev/null @@ -1,16 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "minitest", "~> 5.0" -gem "webmock", "~> 3.0" -gem "standard", "~> 1.0" -gem "rake", "~> 13.0" -gem "appraisal" -gem "faraday-net_http_persistent", "~> 2.1" -gem "connection_pool", "~> 2.4" -gem "railties", ">= 7.1" -gem "puma", "~> 6.0" -gem "rails", "~> 8.0.0" - -gemspec path: "../" diff --git a/lib/octaspace/engine.rb b/lib/octaspace/engine.rb deleted file mode 100644 index f5e97b1..0000000 --- a/lib/octaspace/engine.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module OctaSpace - # Optional Rails Engine - # - # Mount only if you need the built-in playground routes: - # - # # config/routes.rb - # mount OctaSpace::Engine, at: "/octaspace" - # - # This is NOT auto-loaded. Load explicitly when needed: - # require "octaspace/engine" - class Engine < ::Rails::Engine - isolate_namespace OctaSpace - end -end diff --git a/lib/octaspace/playground/payload_presets.rb b/lib/octaspace/playground/payload_presets.rb deleted file mode 100644 index 0bed8ab..0000000 --- a/lib/octaspace/playground/payload_presets.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -require "json" -require "yaml" - -module OctaSpace - module Playground - module PayloadPresets - module_function - - def all - @all ||= load_presets - end - - def fetch(key) - all.fetch(key.to_s) - end - - def payload_for(key) - fetch(key).fetch(:payload) - end - - def payload_json_for(key) - JSON.pretty_generate(payload_for(key)) - end - - def reset! - @all = nil - end - - def path - File.expand_path("payload_presets.yml", __dir__) - end - - def load_presets - raw = YAML.safe_load_file(path) || {} - presets = raw.fetch("presets", {}) - - presets.each_with_object({}) do |(key, value), memo| - memo[key] = deep_symbolize(value) - end - end - - def deep_symbolize(value) - case value - when Hash - value.each_with_object({}) do |(key, nested), memo| - memo[key.to_sym] = deep_symbolize(nested) - end - when Array - value.map { |item| deep_symbolize(item) } - else - value - end - end - - private_class_method :load_presets, :deep_symbolize - end - end -end diff --git a/lib/octaspace/playground/payload_presets.yml b/lib/octaspace/playground/payload_presets.yml deleted file mode 100644 index f32387b..0000000 --- a/lib/octaspace/playground/payload_presets.yml +++ /dev/null @@ -1,58 +0,0 @@ -presets: - services.mr.create: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Mirrors the live MR session payload used by the JS SDK smoke script. - payload: - node_id: 1 - disk_size: 10 - image: ubuntu:24.04 - app: 249b4cb3-3db1-4c06-98a4-772ba88cd81c - - services.render.create: - source: - schema: services_render_request - note: Matches the current OCTA OpenAPI render request schema. - payload: - node_id: 1 - disk_size: 100 - - services.vpn.create: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Mirrors the live VPN session payload used by the JS SDK smoke script. - payload: - node_id: 1 - subkind: wg - - services.session.stop: - source: - script: js-sdk/packages/sdk/scripts/session-test.ts - note: Matches the stop flow exercised in the JS SDK live session test. - payload: - uuid: sess-abc-123 - score: 5 - - services.session.logs: - source: - live_api: - note: Uses the finished-session recent logs branch confirmed against the live API. - payload: - uuid: sess-abc-123 - recent: true - - idle_jobs.find: - source: - fixture: test/fixtures/idle_jobs/show.json - note: Parameter preset for fetching a specific idle job by node and job identifiers. - payload: - node_id: 69 - job_id: 42 - - idle_jobs.logs: - source: - fixture: test/fixtures/idle_jobs/logs.json - note: Parameter preset for fetching idle job logs by node and job identifiers. - payload: - node_id: 69 - job_id: 42 diff --git a/lib/octaspace/playground/smoke_runner.rb b/lib/octaspace/playground/smoke_runner.rb deleted file mode 100644 index a80f0d0..0000000 --- a/lib/octaspace/playground/smoke_runner.rb +++ /dev/null @@ -1,204 +0,0 @@ -# frozen_string_literal: true - -require "time" - -module OctaSpace - module Playground - class SmokeRunner - SUITES = [ - { - id: "network.info", - label: "octa_client.network.info", - method: "GET", - path: "/network", - requires_auth: false, - fn: ->(client) { client.network.info } - }, - { - id: "accounts.profile", - label: "octa_client.accounts.profile", - method: "GET", - path: "/accounts", - requires_auth: true, - fn: ->(client) { client.accounts.profile } - }, - { - id: "accounts.balance", - label: "octa_client.accounts.balance", - method: "GET", - path: "/accounts/balance", - requires_auth: true, - fn: ->(client) { client.accounts.balance } - }, - { - id: "apps.list", - label: "octa_client.apps.list", - method: "GET", - path: "/apps", - requires_auth: true, - fn: ->(client) { client.apps.list } - }, - { - id: "nodes.list", - label: "octa_client.nodes.list", - method: "GET", - path: "/nodes", - requires_auth: true, - fn: ->(client) { client.nodes.list } - }, - { - id: "services.mr.list", - label: "octa_client.services.mr.list", - method: "GET", - path: "/services/mr", - requires_auth: true, - fn: ->(client) { client.services.mr.list } - }, - { - id: "services.render.list", - label: "octa_client.services.render.list", - method: "GET", - path: "/services/render", - requires_auth: true, - fn: ->(client) { client.services.render.list } - }, - { - id: "services.vpn.list", - label: "octa_client.services.vpn.list", - method: "GET", - path: "/services/vpn", - requires_auth: true, - fn: ->(client) { client.services.vpn.list } - }, - { - id: "sessions.list", - label: "octa_client.sessions.list", - method: "GET", - path: "/sessions", - requires_auth: true, - fn: ->(client) { client.sessions.list } - }, - { - id: "sessions.list.recent", - label: "octa_client.sessions.list(recent: true)", - method: "GET", - path: "/sessions?recent=true", - requires_auth: true, - fn: ->(client) { client.sessions.list(recent: true) } - } - ].freeze - - def initialize(client:, executor: nil) - @client = client - @executor = executor || method(:default_execute) - end - - def run - started_at = monotonic_now - suites = SUITES.map { |suite| run_suite(suite) } - passed_count = suites.count { |suite| suite[:status] == "passed" } - failed_count = suites.count { |suite| suite[:status] == "failed" } - - { - kind: "sdk_smoke", - status: failed_count.zero? ? "passed" : "failed", - started_at: Time.now.utc.iso8601, - duration_ms: elapsed_ms(started_at), - summary: { - total: suites.length, - passed: passed_count, - failed: failed_count - }, - suites: - } - end - - private - - attr_reader :client, :executor - - def run_suite(suite) - started_at = monotonic_now - response = executor.call(suite) { suite[:fn].call(client) } - - if response.is_a?(OctaSpace::Error) - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "failed", - duration_ms: elapsed_ms(started_at), - error: { - class: response.class.name, - message: response.message, - status: response.status, - request_id: response.request_id - }.compact - } - else - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "passed", - duration_ms: elapsed_ms(started_at), - http_status: response.status, - request_id: response.request_id, - summary: summarize_payload(response.data) - }.compact - end - rescue => e - { - id: suite[:id], - label: suite[:label], - method: suite[:method], - path: suite[:path], - requires_auth: suite[:requires_auth], - status: "failed", - duration_ms: elapsed_ms(started_at), - error: { - class: e.class.name, - message: e.message - } - } - end - - def summarize_payload(payload) - case payload - when Array - { - type: "array", - count: payload.length, - sample_keys: payload.first.is_a?(Hash) ? payload.first.keys.take(8) : nil - }.compact - when Hash - { - type: "object", - keys: payload.keys.take(12) - } - else - { - type: payload.class.name, - value: payload - } - end - end - - def default_execute(_suite) - yield - end - - def monotonic_now - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - - def elapsed_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - end - end -end diff --git a/lib/octaspace/transport/mock_transport.rb b/lib/octaspace/transport/mock_transport.rb deleted file mode 100644 index 035af10..0000000 --- a/lib/octaspace/transport/mock_transport.rb +++ /dev/null @@ -1,138 +0,0 @@ -# frozen_string_literal: true - -require "securerandom" - -module OctaSpace - module Transport - # Fixture-backed mock transport used by the playground and tests. - # - # Supports one happy-path fixture mode plus the same error-oriented - # scenarios exposed in the JS playground. - class MockTransport < Base - SCENARIOS = [ - {value: "real", label: "Real API", description: "Use the actual OctaSpace API"}, - {value: "401", label: "401 Unauthorized", description: "Simulates invalid or missing API key"}, - {value: "403", label: "403 Forbidden", description: "Simulates insufficient permissions"}, - {value: "404", label: "404 Not Found", description: "Simulates a missing resource"}, - {value: "429", label: "429 Rate Limited", description: "Simulates rate limiting with Retry-After: 60"}, - {value: "500", label: "500 Server Error", description: "Simulates an internal server error"}, - {value: "slow", label: "Slow (3s)", description: "Adds a 3-second delay before responding with fixtures"}, - {value: "timeout", label: "Timeout", description: "Raises a timeout error without contacting the API"}, - {value: "network-error", label: "Network Error", description: "Simulates a connection failure"} - ].freeze - - DEFAULT_DELAY_SECONDS = 3.0 - RawResponse = Struct.new(:status, :headers, :body, keyword_init: true) - - def self.scenarios = SCENARIOS - - def initialize(config, scenario:, delay_seconds: DEFAULT_DELAY_SECONDS) - @config = config - @scenario = scenario.to_s - @delay_seconds = delay_seconds - end - - attr_reader :scenario - - def get(path, params: {}, headers: {}) - request(:get, path, params: params, headers: headers) - end - - def post(path, body: nil, headers: {}) - request(:post, path, body: body, headers: headers) - end - - def put(path, body: nil, headers: {}) - request(:put, path, body: body, headers: headers) - end - - def patch(path, body: nil, headers: {}) - request(:patch, path, body: body, headers: headers) - end - - def delete(path, params: {}, headers: {}) - request(:delete, path, params: params, headers: headers) - end - - def transport_stats - { - mode: :mock, - scenario: scenario, - label: scenario_metadata[:label] - } - end - - private - - attr_reader :config, :delay_seconds - - def request(method, path, params: {}, body: nil, headers: {}) - invoke_hook(config.on_request, {method: method, path: path, params: params, body: body, headers: headers}) - - simulate_transport_failure! - simulate_delay! if scenario == "slow" - - raise ConfigurationError, "MockTransport cannot be used with the real scenario" if scenario == "real" - - response = build_mock_response - - invoke_hook(config.on_response, response) - raise OctaSpace.error_for(response) if response.error? - - response - end - - def mock_response_scenario? - %w[401 403 404 429 500 slow].include?(scenario) - end - - def simulate_transport_failure! - case scenario - when "network-error" - raise ConnectionError, "Mocked network failure" - when "timeout" - raise TimeoutError, "Mocked timeout after #{config.read_timeout}s" - end - end - - def simulate_delay! - sleep(delay_seconds) - end - - def build_mock_response - status = (scenario == "slow") ? 200 : scenario.to_i - headers = {"content-type" => "application/json"} - headers["retry-after"] = "60" if scenario == "429" - - build_response( - status: status, - headers: headers, - body: {"error" => "Mocked #{status}", "scenario" => scenario} - ) - end - - def build_response(status:, body:, headers: {}) - response_headers = { - "content-type" => "application/json", - "x-request-id" => "mock-#{SecureRandom.hex(6)}" - }.merge(headers) - - Response.new( - RawResponse.new( - status: status, - headers: response_headers, - body: body - ) - ) - end - - def scenario_metadata - self.class.scenarios.find { |entry| entry[:value] == scenario } || self.class.scenarios.first - end - - def invoke_hook(hook, *args) - hook&.call(*args) - end - end - end -end diff --git a/octaspace.gemspec b/octaspace.gemspec index 453b233..fa012a9 100644 --- a/octaspace.gemspec +++ b/octaspace.gemspec @@ -14,7 +14,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/octaspace/ruby-sdk" spec.license = "MIT" - spec.required_ruby_version = ">= 3.2.0" + spec.required_ruby_version = ">= 3.3.0" spec.metadata = { "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md", @@ -23,11 +23,10 @@ Gem::Specification.new do |spec| "rubygems_mfa_required" => "true" } - spec.files = Dir["lib/**/*", "MIT-LICENSE", "README.md", "CHANGELOG.md"] - - Dir["lib/octaspace/playground/**/*", "lib/octaspace/transport/mock_transport.rb", "lib/octaspace/engine.rb"] + spec.files = Dir["lib/**/*.rb", "MIT-LICENSE", "README.md", "CHANGELOG.md"] spec.require_paths = ["lib"] - spec.add_dependency "faraday", "~> 2.9" + spec.add_dependency "faraday", ">= 2.14.3", "< 3" spec.add_dependency "faraday-retry", "~> 2.2" # Optional — persistent mode (users add to their own Gemfile): diff --git a/test/dummy/Rakefile b/test/dummy/Rakefile deleted file mode 100644 index 5c0e575..0000000 --- a/test/dummy/Rakefile +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -APP_RAKEFILE = __FILE__ -load "rails/tasks/engine.rake" diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb deleted file mode 100644 index b9495db..0000000 --- a/test/dummy/app/controllers/application_controller.rb +++ /dev/null @@ -1,341 +0,0 @@ -# frozen_string_literal: true - -require "digest" -require "json" -require "octaspace/transport/mock_transport" -require "octaspace/playground/payload_presets" -require "octaspace/playground/smoke_runner" - -class ApplicationController < ActionController::Base - after_action :shutdown_octa_client - - helper_method \ - :playground_diagnostics_preset_path, - :octa_client, - :playground_api_key_present?, - :playground_auth_available?, - :playground_config, - :playground_error_payload, - :playground_format_bytes, - :playground_format_datetime, - :playground_format_duration, - :playground_format_mbps, - :playground_format_short_time, - :playground_json, - :playground_json_html, - :playground_masked_api_key, - :playground_mock_active?, - :playground_mock_scenario, - :playground_mock_scenarios, - :playground_asset_path, - :playground_payload, - :playground_present?, - :playground_query_url, - :playground_timeout_label, - :playground_transport_description, - :playground_transport_label, - :playground_transport_stats, - :playground_truncate - - private - - def octa_client - @octa_client ||= begin - client_options = {api_key: playground_effective_api_key} - client_options[:base_url] = playground_effective_base_url if playground_base_url_override? - client_options[:keep_alive] = false if playground_mock_active? - - if playground_mock_active? - transport = OctaSpace::Transport::MockTransport.new(playground_config, scenario: playground_mock_scenario) - OctaSpace::Client.new(**client_options, transport: transport) - else - OctaSpace::Client.new(**client_options) - end - end - end - - def playground_config - @playground_config ||= begin - config = OctaSpace.configuration.dup - config.api_key = playground_effective_api_key - config.base_url = playground_effective_base_url - config.keep_alive = false if playground_mock_active? - config - end - end - - def playground_transport_stats - @playground_transport_stats ||= octa_client.transport_stats - end - - def playground_api_key_present? - playground_config.api_key.present? - end - - def playground_auth_available? - playground_mock_active? || playground_api_key_present? - end - - def playground_mock_active? - playground_mock_scenario != "real" - end - - def playground_mock_scenario - value = playground_settings["mock_scenario"].presence || "real" - playground_mock_scenarios.any? { |scenario| scenario[:value] == value } ? value : "real" - end - - def playground_mock_scenarios - OctaSpace::Transport::MockTransport.scenarios - end - - def playground_payload(value) - value.respond_to?(:data) ? value.data : value - end - - def playground_diagnostics_preset_path(call_id, overrides = {}) - payload = OctaSpace::Playground::PayloadPresets.payload_for(call_id).merge(compact_present_hash(overrides)) - playground_diagnostics_path(call: call_id, payload_json: JSON.pretty_generate(payload)) - end - - def playground_asset_path(asset_name) - asset_path = Rails.public_path.join(asset_name.to_s.delete_prefix("/")) - return "/#{asset_name}" unless asset_path.file? - - fingerprint = Digest::SHA256.file(asset_path).hexdigest.first(12) - "/#{asset_name}?v=#{fingerprint}" - end - - def playground_present?(value) - !value.nil? - end - - def playground_json(value) - JSON.pretty_generate(playground_serializable(value)) - rescue JSON::GeneratorError - playground_serializable(value).inspect - end - - def playground_json_html(value) - render_json_node(playground_serializable(value), 0) - end - - def playground_error_payload(error) - payload = { - class: error.class.name, - message: error.message, - status: error.status, - request_id: error.request_id - } - - response_data = error.response&.data - payload[:response] = response_data if response_data.present? - payload[:retry_after] = error.retry_after if error.respond_to?(:retry_after) && error.retry_after.present? - payload.compact - end - - def playground_query_url(param, value) - url_for(request.path_parameters.merge(request.query_parameters).merge(param.to_s => value, only_path: true)) - end - - def playground_masked_api_key - key = playground_config.api_key.to_s - return "" if key.empty? - return ("*" * key.length) if key.length <= 8 - - "#{key[0, 4]}#{'*' * [key.length - 8, 4].max}#{key[-4, 4]}" - end - - def playground_transport_label - return playground_selected_scenario[:label] if playground_mock_active? - - playground_config.keep_alive? ? "Real API / Keep-Alive" : "Real API" - end - - def playground_transport_description - return playground_selected_scenario[:description] if playground_mock_active? - - playground_config.keep_alive? ? "Uses the real API through the persistent transport" : "Uses the real API through the standard Faraday transport" - end - - def playground_timeout_label - "#{playground_config.open_timeout}s / #{playground_config.read_timeout}s" - end - - def playground_format_duration(value) - seconds = value.to_i - return "—" if seconds <= 0 - - hours = seconds / 3600 - minutes = (seconds % 3600) / 60 - remainder = seconds % 60 - - return "#{hours}h #{minutes}m" if hours.positive? - return "#{minutes}m #{remainder}s" if minutes.positive? - - "#{remainder}s" - end - - def playground_format_bytes(value) - bytes = value.to_f - return "0 B" unless bytes.positive? - - units = %w[B KB MB GB TB] - index = 0 - - while bytes >= 1024 && index < units.length - 1 - bytes /= 1024.0 - index += 1 - end - - format("%.1f %s", bytes, units[index]) - end - - def playground_format_mbps(value) - normalized = OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth(value) - return "—" if normalized.blank? - - format("%.1f Mbps", normalized) - end - - def playground_format_datetime(value) - return "—" if value.blank? - - timestamp = - case value - when Time, DateTime, ActiveSupport::TimeWithZone - value - when Numeric - numeric = value.to_f - numeric > 10_000_000_000 ? Time.zone.at(numeric / 1000.0) : Time.zone.at(numeric) - else - Time.zone.parse(value.to_s) - end - - timestamp&.strftime("%b %-d, %H:%M") || "—" - rescue ArgumentError, TypeError - value.to_s - end - - def playground_format_short_time(value) - return "—" if value.blank? - - timestamp = - case value - when Time, DateTime, ActiveSupport::TimeWithZone - value - when Numeric - value.to_f > 10_000_000_000 ? Time.zone.at(value.to_f / 1000.0) : Time.zone.at(value.to_f) - else - Time.zone.parse(value.to_s) - end - - timestamp&.strftime("%H:%M:%S") || value.to_s - rescue ArgumentError, TypeError - value.to_s - end - - def playground_truncate(value, leading: 8, trailing: 5) - string = value.to_s - return string if string.length <= (leading + trailing + 1) - - "#{string[0, leading]}…#{string[-trailing, trailing]}" - end - - def playground_serializable(value) - serializable = playground_payload(value) - - case serializable - when OctaSpace::Error - playground_error_payload(serializable) - when Hash, Array, String, Numeric, TrueClass, FalseClass, NilClass - serializable - when Symbol - serializable.to_s - else - serializable.respond_to?(:to_h) ? serializable.to_h : serializable.to_s - end - end - - def compact_present_hash(hash) - hash.each_with_object({}) do |(key, value), result| - result[key] = value if value.present? - end - end - - def render_json_node(node, level) - case node - when Hash - render_json_hash(node, level) - when Array - render_json_array(node, level) - when String - %(#{ERB::Util.html_escape(node.to_json)}).html_safe - when Numeric - %(#{node}).html_safe - when TrueClass, FalseClass - %(#{node}).html_safe - when NilClass - %(null).html_safe - else - %(#{ERB::Util.html_escape(node.to_s.to_json)}).html_safe - end - end - - def render_json_hash(hash, level) - return "{}".html_safe if hash.empty? - - lines = hash.map do |key, value| - %(#{json_indent(level + 1)}#{ERB::Util.html_escape(key.to_s.to_json)}: #{render_json_node(value, level + 1)}) - end - - "{\n#{lines.join(",\n")}\n#{json_indent(level)}}".html_safe - end - - def render_json_array(array, level) - return "[]".html_safe if array.empty? - - lines = array.map do |value| - "#{json_indent(level + 1)}#{render_json_node(value, level + 1)}" - end - - "[\n#{lines.join(",\n")}\n#{json_indent(level)}]".html_safe - end - - def json_indent(level) - " " * level - end - - def shutdown_octa_client - @octa_client&.shutdown - @octa_client = nil - end - - def playground_settings - session[:playground_settings] ||= {} - end - - def playground_selected_scenario - playground_mock_scenarios.find { |scenario| scenario[:value] == playground_mock_scenario } || playground_mock_scenarios.first - end - - def playground_effective_api_key - if playground_settings.key?("api_key") - playground_settings["api_key"].to_s - else - OctaSpace.configuration.api_key.to_s - end - end - - def playground_effective_base_url - if playground_base_url_override? - playground_settings["base_url"].to_s - else - OctaSpace.configuration.base_url.to_s - end - end - - def playground_base_url_override? - playground_settings["base_url"].present? - end -end diff --git a/test/dummy/app/controllers/playground/accounts_controller.rb b/test/dummy/app/controllers/playground/accounts_controller.rb deleted file mode 100644 index 537fbec..0000000 --- a/test/dummy/app/controllers/playground/accounts_controller.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Playground - class AccountsController < BaseController - def show - if playground_api_key_present? - @profile = capture_api_call(label: "octa_client.accounts.profile", path: "/accounts") { octa_client.accounts.profile } - @balance = capture_api_call(label: "octa_client.accounts.balance", path: "/accounts/balance") { octa_client.accounts.balance } - else - @profile = nil - @balance = nil - end - end - end -end diff --git a/test/dummy/app/controllers/playground/apps_controller.rb b/test/dummy/app/controllers/playground/apps_controller.rb deleted file mode 100644 index d74f994..0000000 --- a/test/dummy/app/controllers/playground/apps_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Playground - class AppsController < BaseController - def show - @apps = capture_api_call(label: "octa_client.apps.list", path: "/apps") { octa_client.apps.list } - end - end -end diff --git a/test/dummy/app/controllers/playground/base_controller.rb b/test/dummy/app/controllers/playground/base_controller.rb deleted file mode 100644 index a9ce030..0000000 --- a/test/dummy/app/controllers/playground/base_controller.rb +++ /dev/null @@ -1,78 +0,0 @@ -# frozen_string_literal: true - -require "securerandom" - -module Playground - class BaseController < ApplicationController - before_action :initialize_request_log - - private - - def initialize_request_log - session.delete(:playground_request_log) - @request_log_key = playground_request_log_key - @request_log = Array(playground_request_log_store[@request_log_key]) - end - - def capture_api_call(label: nil, method: "GET", path: nil) - started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - response = yield - log_request( - label:, - method:, - path: path, - status: response.status, - duration_ms: duration_ms(started_at) - ) - response - rescue OctaSpace::Error => e - log_request( - label:, - method:, - path: path, - status: e.status || "ERR", - duration_ms: duration_ms(started_at), - error: e.message - ) - e - end - - def duration_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - - def log_request(label:, method:, path:, status:, duration_ms:, error: nil) - @request_log << { - "label" => label, - "method" => method, - "path" => path, - "status" => status, - "duration_ms" => duration_ms, - "timestamp" => Time.current.iso8601, - "error" => error - } - - playground_request_log_mutex.synchronize do - playground_request_log_store[@request_log_key] = @request_log.last(100) - end - end - - def clear_request_log! - playground_request_log_mutex.synchronize do - playground_request_log_store.delete(@request_log_key) - end - end - - def playground_request_log_key - session[:playground_request_log_token] ||= SecureRandom.hex(12) - end - - def playground_request_log_store - Rails.application.config.x.playground_request_logs ||= {} - end - - def playground_request_log_mutex - Rails.application.config.x.playground_request_log_mutex ||= Mutex.new - end - end -end diff --git a/test/dummy/app/controllers/playground/dashboard_controller.rb b/test/dummy/app/controllers/playground/dashboard_controller.rb deleted file mode 100644 index 56eb202..0000000 --- a/test/dummy/app/controllers/playground/dashboard_controller.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Playground - class DashboardController < BaseController - def show - @network = capture_api_call(label: "octa_client.network.info", path: "/network") { octa_client.network.info } - - if playground_api_key_present? - @profile = capture_api_call(label: "octa_client.accounts.profile", path: "/accounts") { octa_client.accounts.profile } - @balance = capture_api_call(label: "octa_client.accounts.balance", path: "/accounts/balance") { octa_client.accounts.balance } - else - @profile = nil - @balance = nil - end - end - end -end diff --git a/test/dummy/app/controllers/playground/diagnostics_controller.rb b/test/dummy/app/controllers/playground/diagnostics_controller.rb deleted file mode 100644 index 31185e0..0000000 --- a/test/dummy/app/controllers/playground/diagnostics_controller.rb +++ /dev/null @@ -1,329 +0,0 @@ -# frozen_string_literal: true - -module Playground - class DiagnosticsController < BaseController - SDK_CALLS = [ - { - id: "network.info", - label: "octa_client.network.info", - description: "GET /network — public, no auth required", - method: "GET", - path: "/network", - requires_auth: false - }, - { - id: "accounts.profile", - label: "octa_client.accounts.profile", - description: "GET /accounts — requires API key", - method: "GET", - path: "/accounts", - requires_auth: true - }, - { - id: "accounts.balance", - label: "octa_client.accounts.balance", - description: "GET /accounts/balance — requires API key", - method: "GET", - path: "/accounts/balance", - requires_auth: true - }, - { - id: "apps.list", - label: "octa_client.apps.list", - description: "GET /apps — requires API key; live API may serialize ports/http_ports as JSON strings", - method: "GET", - path: "/apps", - requires_auth: true - }, - { - id: "idle_jobs.find", - label: "octa_client.idle_jobs.find(node_id:, job_id:)", - description: "GET /idle_jobs/:node_id/:job_id — requires API key", - method: "GET", - path: "/idle_jobs/:node_id/:job_id", - requires_auth: true, - payload: true - }, - { - id: "idle_jobs.logs", - label: "octa_client.idle_jobs.logs(node_id:, job_id:)", - description: "GET /idle_jobs/:node_id/:job_id/logs — requires API key", - method: "GET", - path: "/idle_jobs/:node_id/:job_id/logs", - requires_auth: true, - payload: true - }, - { - id: "nodes.list", - label: "octa_client.nodes.list", - description: "GET /nodes — requires API key", - method: "GET", - path: "/nodes", - requires_auth: true - }, - { - id: "sessions.list", - label: "octa_client.sessions.list", - description: "GET /sessions — requires API key", - method: "GET", - path: "/sessions", - requires_auth: true - }, - { - id: "sessions.list.recent", - label: "octa_client.sessions.list(recent: true)", - description: "GET /sessions?recent=true — requires API key; live telemetry fields may be serialized as strings", - method: "GET", - path: "/sessions?recent=true", - requires_auth: true - }, - { - id: "services.session.logs", - label: "octa_client.services.session(uuid).logs", - description: "GET /services/:uuid/logs — for finished sessions use recent=true", - method: "GET", - path: "/services/:uuid/logs", - requires_auth: true, - payload: true - }, - { - id: "services.mr.list", - label: "octa_client.services.mr.list", - description: "GET /services/mr — requires API key; returns marketplace machine catalog", - method: "GET", - path: "/services/mr", - requires_auth: true - }, - { - id: "services.render.list", - label: "octa_client.services.render.list", - description: "GET /services/render — requires API key; returns render marketplace catalog", - method: "GET", - path: "/services/render", - requires_auth: true - }, - { - id: "services.vpn.list", - label: "octa_client.services.vpn.list", - description: "GET /services/vpn — requires API key; returns VPN relay catalog", - method: "GET", - path: "/services/vpn", - requires_auth: true - }, - { - id: "services.mr.create", - label: "octa_client.services.mr.create", - description: "POST /services/mr — requires API key; live API uses array payloads and may reject item-level with HTTP 200", - method: "POST", - path: "/services/mr", - requires_auth: true, - mutation: true - }, - { - id: "services.render.create", - label: "octa_client.services.render.create", - description: "POST /services/render — requires API key", - method: "POST", - path: "/services/render", - requires_auth: true, - mutation: true - }, - { - id: "services.vpn.create", - label: "octa_client.services.vpn.create", - description: "POST /services/vpn — requires API key", - method: "POST", - path: "/services/vpn", - requires_auth: true, - mutation: true - }, - { - id: "services.session.stop", - label: "octa_client.services.session(uuid).stop", - description: "POST /services/:uuid/stop — requires API key", - method: "POST", - path: "/services/:uuid/stop", - requires_auth: true, - mutation: true - } - ].freeze - - def show - prepare_state - @payload_json = - if call_uses_payload?(@selected_call) - params[:payload_json].presence || default_payload_json(@selected_call) - else - "" - end - end - - def run - prepare_state - @selected_call = find_call(params[:call]) - @selected_call_id = @selected_call[:id] - @payload_json = params[:payload_json].to_s - @payload_json = default_payload_json(@selected_call) if @payload_json.empty? && call_uses_payload?(@selected_call) - - started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @result = execute_call(@selected_call) - @result[:duration_ms] = duration_ms(started_at) - render :show - end - - def smoke - prepare_state - @payload_json = default_payload_json(@selected_call) - runner = OctaSpace::Playground::SmokeRunner.new(client: octa_client, executor: method(:execute_smoke_suite)) - smoke_result = runner.run - @result = { - status: :smoke, - duration_ms: smoke_result[:duration_ms], - data: smoke_result - } - render :show - rescue StandardError => e - @result = {status: :error, error: wrap_smoke_error(e)} - render :show - end - - private - - def prepare_state - @config = playground_config - @transport_stats = playground_transport_stats - @rotator_stats = extract_rotator_stats - @calls = SDK_CALLS - @selected_call_id = params[:call].presence || @calls.first[:id] - @selected_call = find_call(@selected_call_id) - @result = {status: :idle} - end - - def find_call(call_id) - @calls.find { |call| call[:id] == call_id } || @calls.first - end - - def execute_call(call) - payload = call_uses_payload?(call) ? parse_payload!(call, @payload_json) : nil - path = resolved_path(call, payload) - - response = capture_api_call(label: call[:label], method: call[:method], path:) do - case call[:id] - when "network.info" - octa_client.network.info - when "accounts.profile" - octa_client.accounts.profile - when "accounts.balance" - octa_client.accounts.balance - when "apps.list" - octa_client.apps.list - when "idle_jobs.find" - octa_client.idle_jobs.find(node_id: payload.fetch(:node_id), job_id: payload.fetch(:job_id)) - when "idle_jobs.logs" - octa_client.idle_jobs.logs(node_id: payload.fetch(:node_id), job_id: payload.fetch(:job_id)) - when "nodes.list" - octa_client.nodes.list - when "sessions.list" - octa_client.sessions.list - when "sessions.list.recent" - octa_client.sessions.list(recent: true) - when "services.session.logs" - uuid = payload.fetch(:uuid).to_s - octa_client.services.session(uuid).logs(recent: payload[:recent]) - when "services.mr.list" - octa_client.services.mr.list - when "services.render.list" - octa_client.services.render.list - when "services.vpn.list" - octa_client.services.vpn.list - when "services.mr.create" - octa_client.services.mr.create(**payload) - when "services.render.create" - octa_client.services.render.create(**payload) - when "services.vpn.create" - octa_client.services.vpn.create(**payload) - when "services.session.stop" - uuid = payload.fetch(:uuid).to_s - octa_client.services.session(uuid).stop(**payload.except(:uuid)) - end - end - - return {status: :error, error: response} if response.is_a?(OctaSpace::Error) - - {status: :success, data: response.data, response: response} - rescue OctaSpace::Error => e - {status: :error, error: e} - end - - def duration_ms(started_at) - ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round - end - - def execute_smoke_suite(suite) - capture_api_call(label: suite[:label], method: suite[:method], path: suite[:path]) { yield } - end - - def default_payload_json(call) - return "" unless call_uses_payload?(call) - - OctaSpace::Playground::PayloadPresets.payload_json_for(call[:id]) - end - - def parse_payload!(call, payload_json) - if payload_json.blank? - raise OctaSpace::ValidationError.new("Payload JSON is required for #{call[:label]}") - end - - payload = JSON.parse(payload_json) - raise OctaSpace::ValidationError.new("Payload JSON must be an object") unless payload.is_a?(Hash) - - payload = payload.deep_symbolize_keys - validate_mutation_payload!(call, payload) - payload - rescue JSON::ParserError => e - raise OctaSpace::ValidationError.new("Invalid payload JSON: #{e.message}") - end - - def validate_mutation_payload!(call, payload) - case call[:id] - when "services.session.logs", "services.session.stop" - raise OctaSpace::ValidationError.new("Payload must include a non-empty uuid") if payload[:uuid].to_s.strip.empty? - when "idle_jobs.find", "idle_jobs.logs" - raise OctaSpace::ValidationError.new("Payload must include node_id") if payload[:node_id].to_s.strip.empty? - raise OctaSpace::ValidationError.new("Payload must include job_id") if payload[:job_id].to_s.strip.empty? - end - end - - def resolved_path(call, payload) - case call[:id] - when "services.session.logs" - path = "/services/#{payload.fetch(:uuid)}/logs" - payload[:recent] ? "#{path}?recent=true" : path - when "services.session.stop" - "/services/#{payload.fetch(:uuid)}/stop" - when "idle_jobs.find" - "/idle_jobs/#{payload.fetch(:node_id)}/#{payload.fetch(:job_id)}" - when "idle_jobs.logs" - "/idle_jobs/#{payload.fetch(:node_id)}/#{payload.fetch(:job_id)}/logs" - else - call[:path] - end - end - - def call_uses_payload?(call) - call[:mutation] || call[:payload] - end - - def wrap_smoke_error(error) - return error if error.is_a?(OctaSpace::Error) - - OctaSpace::Error.new("Smoke runner failed: #{error.message}") - end - - def extract_rotator_stats - transport = octa_client.instance_variable_get(:@transport) - rotator = transport.instance_variable_get(:@rotator) - rotator&.stats - end - end -end diff --git a/test/dummy/app/controllers/playground/idle_jobs_controller.rb b/test/dummy/app/controllers/playground/idle_jobs_controller.rb deleted file mode 100644 index bfb447c..0000000 --- a/test/dummy/app/controllers/playground/idle_jobs_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Playground - class IdleJobsController < BaseController - def show - @node_id = params[:node_id].presence || default_payload[:node_id].to_s - @job_id = params[:job_id].presence || default_payload[:job_id].to_s - - return if params[:node_id].blank? || params[:job_id].blank? - - @job = capture_api_call( - label: "octa_client.idle_jobs.find(node_id: #{@node_id}, job_id: #{@job_id})", - path: "/idle_jobs/#{@node_id}/#{@job_id}" - ) do - octa_client.idle_jobs.find(node_id: @node_id, job_id: @job_id) - end - - @logs = capture_api_call( - label: "octa_client.idle_jobs.logs(node_id: #{@node_id}, job_id: #{@job_id})", - path: "/idle_jobs/#{@node_id}/#{@job_id}/logs" - ) do - octa_client.idle_jobs.logs(node_id: @node_id, job_id: @job_id) - end - end - - private - - def default_payload - OctaSpace::Playground::PayloadPresets.payload_for("idle_jobs.find") - end - end -end diff --git a/test/dummy/app/controllers/playground/network_controller.rb b/test/dummy/app/controllers/playground/network_controller.rb deleted file mode 100644 index 23a5fb5..0000000 --- a/test/dummy/app/controllers/playground/network_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module Playground - class NetworkController < BaseController - def show - @network = capture_api_call(label: "octa_client.network.info", path: "/network") { octa_client.network.info } - end - end -end diff --git a/test/dummy/app/controllers/playground/nodes_controller.rb b/test/dummy/app/controllers/playground/nodes_controller.rb deleted file mode 100644 index 6fd2b46..0000000 --- a/test/dummy/app/controllers/playground/nodes_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Playground - class NodesController < BaseController - def index - @nodes = capture_api_call(label: "octa_client.nodes.list", path: "/nodes") { octa_client.nodes.list } - @selected_node_id = params[:selected_id].presence || params[:id].presence - - return if @selected_node_id.blank? - - @selected_node = capture_api_call(label: "octa_client.nodes.find(#{@selected_node_id})", path: "/nodes/#{@selected_node_id}") do - octa_client.nodes.find(@selected_node_id) - end - end - - def show - @node = capture_api_call(label: "octa_client.nodes.find(#{params[:id]})", path: "/nodes/#{params[:id]}") do - octa_client.nodes.find(params[:id]) - end - end - end -end diff --git a/test/dummy/app/controllers/playground/request_logs_controller.rb b/test/dummy/app/controllers/playground/request_logs_controller.rb deleted file mode 100644 index 6da33f6..0000000 --- a/test/dummy/app/controllers/playground/request_logs_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Playground - class RequestLogsController < BaseController - def destroy - clear_request_log! - redirect_back fallback_location: playground_dashboard_path - end - end -end diff --git a/test/dummy/app/controllers/playground/services_controller.rb b/test/dummy/app/controllers/playground/services_controller.rb deleted file mode 100644 index f324800..0000000 --- a/test/dummy/app/controllers/playground/services_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module Playground - class ServicesController < BaseController - def show - @active_tab = params[:tab].presence_in(%w[mr render vpn]) || "mr" - @mr_catalog = capture_api_call(label: "octa_client.services.mr.list", path: "/services/mr") { octa_client.services.mr.list } - @render_catalog = capture_api_call(label: "octa_client.services.render.list", path: "/services/render") { octa_client.services.render.list } - @vpn_catalog = capture_api_call(label: "octa_client.services.vpn.list", path: "/services/vpn") { octa_client.services.vpn.list } - end - end -end diff --git a/test/dummy/app/controllers/playground/sessions_controller.rb b/test/dummy/app/controllers/playground/sessions_controller.rb deleted file mode 100644 index bb116c7..0000000 --- a/test/dummy/app/controllers/playground/sessions_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -module Playground - class SessionsController < BaseController - def index - @active_tab = params[:tab].presence_in(%w[current recent]) || "current" - load_sessions - @stop_uuid = params[:stop_uuid].presence - end - - def stop - @active_tab = params[:tab].presence_in(%w[current recent]) || "current" - uuid = params[:uuid].to_s.strip - - if uuid.blank? - flash[:playground_stop_error] = "Session UUID is required" - redirect_to playground_sessions_path(tab: @active_tab), status: :see_other - return - end - - stop_params = {} - stop_params[:score] = params[:score].to_i if params[:score].present? - - response = capture_api_call( - label: "octa_client.services.session(#{uuid}).stop", - method: "POST", - path: "/services/#{uuid}/stop" - ) do - octa_client.services.session(uuid).stop(**stop_params) - end - - if response.is_a?(OctaSpace::Error) - flash[:playground_stop_error] = response.message - else - flash[:playground_stop_notice] = { - "message" => "Session #{uuid} stop request submitted.", - "request_id" => response.request_id - } - end - - redirect_to playground_sessions_path(tab: @active_tab), status: :see_other - end - - private - - def load_sessions - @current_sessions = capture_api_call(label: "octa_client.sessions.list", path: "/sessions") { octa_client.sessions.list } - @recent_sessions = capture_api_call(label: "octa_client.sessions.list(recent: true)", path: "/sessions?recent=true") do - octa_client.sessions.list(recent: true) - end - end - end -end diff --git a/test/dummy/app/controllers/playground/settings_controller.rb b/test/dummy/app/controllers/playground/settings_controller.rb deleted file mode 100644 index e11763f..0000000 --- a/test/dummy/app/controllers/playground/settings_controller.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Playground - class SettingsController < ApplicationController - def update - settings = session[:playground_settings] ||= {} - - settings["api_key"] = params[:api_key].to_s if params.key?(:api_key) - - if params.key?(:base_url) - base_url = params[:base_url].to_s.strip - base_url.present? ? settings["base_url"] = base_url : settings.delete("base_url") - end - - if params.key?(:mock_scenario) - scenario = params[:mock_scenario].to_s - settings["mock_scenario"] = valid_scenario?(scenario) ? scenario : "real" - end - - redirect_to safe_return_path - end - - def destroy - session.delete(:playground_settings) - redirect_to safe_return_path - end - - private - - def valid_scenario?(value) - OctaSpace::Transport::MockTransport.scenarios.any? { |scenario| scenario[:value] == value } - end - - def safe_return_path - path = params[:return_to].to_s - return playground_dashboard_path unless path.start_with?("/") && !path.start_with?("//") - - path - end - end -end diff --git a/test/dummy/app/views/layouts/application.html.erb b/test/dummy/app/views/layouts/application.html.erb deleted file mode 100644 index 62eb156..0000000 --- a/test/dummy/app/views/layouts/application.html.erb +++ /dev/null @@ -1,180 +0,0 @@ - - - - OctaSpace SDK Playground - - <%= csrf_meta_tags %> - "> - - - - -
- - -
-
- -
- <%= yield %> -
-
- -
-
-
-
-

Request Log

-
<%= @request_log&.length || 0 %>
-
- <% if @request_log.present? %> - <%= button_to "Clear", playground_request_log_path, method: :delete, form: { class: "inline" }, class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- - <% if @request_log.present? %> -
- - - - - - - - - - - - <% @request_log.reverse_each do |entry| %> - - - - - - - - <% end %> - -
MethodPathStatusDurationTime
<%= entry["method"] %>"><%= entry["path"] || entry["label"] || "—" %> - <% if entry["error"].present? %> - ">ERR - <% else %> - "><%= entry["status"] %> - <% end %> - <%= entry["duration_ms"] %>ms<%= playground_format_short_time(entry["timestamp"]) %>
-
- <% else %> -
No requests yet
- <% end %> -
-
-
-
- - diff --git a/test/dummy/app/views/playground/accounts/show.html.erb b/test/dummy/app/views/playground/accounts/show.html.erb deleted file mode 100644 index b9b4a23..0000000 --- a/test/dummy/app/views/playground/accounts/show.html.erb +++ /dev/null @@ -1,66 +0,0 @@ -<% profile_data = @profile.respond_to?(:data) ? @profile.data : nil %> -<% balance_data = @balance.respond_to?(:data) ? @balance.data : nil %> -<% profile_error = @profile if @profile.is_a?(OctaSpace::Error) %> -<% balance_error = @balance if @balance.is_a?(OctaSpace::Error) %> -<% profile_hash = profile_data || {} %> -<% balance_hash = balance_data || {} %> -<% balance_value = balance_data && (balance_hash["balance"] || balance_hash["amount"] || balance_hash.values.first) %> -<% profile_formatted = capture do %> -
-
- Account UUID - <%= profile_hash["account_uuid"] || profile_hash.dig("account", "uuid") || "—" %> -
-
- Email - <%= profile_hash["email"] || profile_hash.dig("account", "email") || "—" %> -
-
- Avatar - <%= profile_hash["avatar"] || "—" %> -
-
-<% end %> -<% balance_formatted = capture do %> -
-
-
Balance
-
<%= balance_value || "—" %>
-
-
-
Currency
-
<%= balance_hash["currency"] || balance_hash["unit"] || "OCTA" %>
-
-
-<% end %> - -
-
-

Account

-

- Direct account view for the Ruby playground. The Dashboard uses the same panels in a condensed layout. -

-
- -
- <%= render "playground/shared/resource_panel", - title: "Profile", - hook: "octa_client.accounts.profile", - data: profile_data, - error: profile_error, - locked: !playground_api_key_present?, - tab_param: :profile_tab, - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: profile_formatted %> - - <%= render "playground/shared/resource_panel", - title: "Balance", - hook: "octa_client.accounts.balance", - data: balance_data, - error: balance_error, - locked: !playground_api_key_present?, - tab_param: :balance_tab, - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: balance_formatted %> -
-
diff --git a/test/dummy/app/views/playground/apps/show.html.erb b/test/dummy/app/views/playground/apps/show.html.erb deleted file mode 100644 index 1b1a496..0000000 --- a/test/dummy/app/views/playground/apps/show.html.erb +++ /dev/null @@ -1,95 +0,0 @@ -<% apps_data = @apps.respond_to?(:data) ? Array(@apps.data) : [] %> -<% apps_error = @apps if @apps.is_a?(OctaSpace::Error) %> - -
-
-
-

Apps

-

- octa_client.apps.list — available application catalog for service sessions. -

-

- Port counts below normalize live payloads even when the API serializes ports and http_ports as JSON strings. -

-
-
- <% if apps_data.any? %> - - <% end %> - <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_apps_path(request.query_parameters), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% if apps_error %> - <%= render "playground/shared/error_card", error: apps_error %> - <% elsif apps_data.any? %> -
-
- <%= apps_data.length %> apps - octa_client.apps.list -
-
- - - - - - - - - - - - - <% apps_data.each do |app| %> - <% app_uuid = app["uuid"].presence %> - <% app_image = app["image"].presence || app["docker_image"].presence %> - - - - - - - - - <% end %> - -
NameCategoryUUIDImagePortsActions
-
<%= app["name"] || "—" %>
- <% if app["description"].present? %> -
<%= app["description"] %>
- <% end %> -
<%= app["category"] || "—" %>"><%= app["uuid"].presence || "—" %><%= app["image"] || app["docker_image"] || "—" %> - <%= [ - OctaSpace::PayloadHelpers.parse_port_list(app["ports"]).length, - OctaSpace::PayloadHelpers.parse_port_list(app["http_ports"]).length - ].sum %> - - <% if app_uuid.present? %> - <%= link_to "Use in MR Create", - playground_diagnostics_preset_path("services.mr.create", app: app_uuid, image: app_image), - class: "inline-flex items-center rounded border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-[11px] font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> - <% else %> - No UUID - <% end %> -
-
-
- -
-
Raw JSON
- <%= render "playground/shared/json_viewer", data: apps_data, max_height: "max-h-80" %> -
- <% else %> -
No apps found.
- <% end %> -
diff --git a/test/dummy/app/views/playground/dashboard/show.html.erb b/test/dummy/app/views/playground/dashboard/show.html.erb deleted file mode 100644 index 9b7de28..0000000 --- a/test/dummy/app/views/playground/dashboard/show.html.erb +++ /dev/null @@ -1,111 +0,0 @@ -<% network_data = @network.respond_to?(:data) ? @network.data : nil %> -<% profile_data = @profile.respond_to?(:data) ? @profile.data : nil %> -<% balance_data = @balance.respond_to?(:data) ? @balance.data : nil %> -<% network_error = @network if @network.is_a?(OctaSpace::Error) %> -<% profile_error = @profile if @profile.is_a?(OctaSpace::Error) %> -<% balance_error = @balance if @balance.is_a?(OctaSpace::Error) %> -<% profile_hash = profile_data || {} %> -<% balance_hash = balance_data || {} %> -<% balance_value = balance_data && (balance_hash["balance"] || balance_hash["amount"] || balance_hash.values.first) %> -<% account_formatted = capture do %> -
-
- Account UUID - <%= profile_hash["account_uuid"] || profile_hash.dig("account", "uuid") || "—" %> -
-
- Email - <%= profile_hash["email"] || profile_hash.dig("account", "email") || "—" %> -
-
- Avatar - <%= profile_hash["avatar"] || "—" %> -
-
-<% end %> -<% balance_formatted = capture do %> -
-
-
Balance
-
<%= balance_value || "—" %>
-
-
-
Currency
-
<%= balance_hash["currency"] || balance_hash["unit"] || "OCTA" %>
-
-
-<% end %> - -
-
-

Dashboard

-

- Account health check. Demonstrates octa_client resource calls in the Rails dummy app with runtime real/mock transport switching. -

-
- -
- - - <% if playground_mock_active? %> - Mock transport active — - <%= playground_transport_label %> - <% elsif playground_api_key_present? %> - API key set — - <%= playground_config.urls.first %> - <% else %> - No API key — enter one in the sidebar to test authenticated endpoints - <% end %> - - <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% elsif playground_config.keep_alive? %> - Keep-Alive - <% end %> -
- -
-
-
-
-

Network Stats

- public -
- octa_client.network.info -
- <%= link_to "Open", playground_network_path, class: "rounded border border-slate-700 px-2.5 py-1 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- - <% if network_error %> - <%= render "playground/shared/error_card", error: network_error, max_height: "max-h-40" %> - <% elsif network_data %> - <%= render "playground/shared/json_viewer", data: network_data, max_height: "max-h-40" %> - <% else %> -
No network payload available.
- <% end %> -
- -
- <%= render "playground/shared/resource_panel", - title: "Account", - hook: "octa_client.accounts.profile", - data: profile_data, - error: profile_error, - locked: !playground_api_key_present?, - tab_param: :account_tab, - empty_copy: "Account data is unavailable.", - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: account_formatted %> - - <%= render "playground/shared/resource_panel", - title: "Balance", - hook: "octa_client.accounts.balance", - data: balance_data, - error: balance_error, - locked: !playground_api_key_present?, - tab_param: :balance_tab, - empty_copy: "Balance data is unavailable.", - locked_copy: "Enter an API key in the sidebar to load this resource", - formatted: balance_formatted %> -
-
diff --git a/test/dummy/app/views/playground/diagnostics/show.html.erb b/test/dummy/app/views/playground/diagnostics/show.html.erb deleted file mode 100644 index 4169332..0000000 --- a/test/dummy/app/views/playground/diagnostics/show.html.erb +++ /dev/null @@ -1,137 +0,0 @@ -
-
-
-

Diagnostics

-

- Trigger any SDK method directly and inspect the result or error. Switch the transport in the sidebar to test mock scenarios and typed failures. -

-
- <%= form_with url: playground_diagnostics_smoke_path, method: :post, local: true do %> - <%= hidden_field_tag :call, @selected_call_id %> - <%= button_tag "Run SDK Smoke", type: "submit", class: "rounded border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-400 transition-colors hover:bg-emerald-500/20 hover:text-emerald-300" %> - <% end %> -
- -
-
-
-
<%= playground_transport_label %>
-
- <%= playground_transport_description %>. Open timeout <%= @config.open_timeout %>s, read timeout <%= @config.read_timeout %>s, retries <%= @config.max_retries %>. -
-
- <% if !playground_mock_active? && !playground_api_key_present? %> -
No API key — authenticated calls will fail until one is set
- <% end %> -
- -
-
-

SDK Method

-
- <% @calls.each do |call| %> - <% selected = call[:id] == @selected_call_id %> -
- <%= link_to(playground_diagnostics_path(call: call[:id]), class: "block") do %> -
-
-
<%= call[:label] %>
-
<%= call[:description] %>
-
- <% if call[:mutation] %> - mutation - <% end %> - <% if call[:requires_auth] %> - auth - <% end %> -
- <% end %> - - <% if selected %> -
- <%= form_with url: playground_diagnostics_run_path, method: :post, local: true do %> - <%= hidden_field_tag :call, call[:id] %> - <% if call[:mutation] || call[:payload] %> -
- - <%= text_area_tag :payload_json, - @payload_json, - rows: 8, - spellcheck: false, - class: "w-full rounded-lg border border-slate-800 bg-slate-950/80 px-3 py-2 font-mono text-[11px] leading-5 text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -

Top-level JSON keys are forwarded as Ruby keyword arguments. For services.session.stop and services.session.logs, include uuid. For services.session.logs, set recent: true when checking finished sessions. For services.mr.create, optional organization_id and project_id are forwarded. For idle_jobs.*, include node_id and job_id.

-
- <% end %> - <%= button_tag "Run Method", type: "submit", class: "w-full rounded border border-cyan-500/20 bg-cyan-500/10 py-1.5 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> - <% end %> -
- <% end %> -
- <% end %> -
-
- -
-
-

Result

- <% if @result[:duration_ms] %> - <%= @result[:duration_ms] %>ms - <% end %> -
- - <% case @result[:status] %> - <% when :idle %> -

Press Run to execute the selected method.

- <% when :smoke %> - <% smoke = @result[:data] %> -
-
- - - SDK Smoke <%= smoke[:status] == "passed" ? "passed" : "finished with failures" %> - -
-
-
-
Total
-
<%= smoke.dig(:summary, :total) %>
-
-
-
Passed
-
<%= smoke.dig(:summary, :passed) %>
-
-
-
Failed
-
<%= smoke.dig(:summary, :failed) %>
-
-
- <%= render "playground/shared/json_viewer", data: smoke %> -
- <% when :success %> -
-
- - Success -
-
-
-
Status
-
<%= @result[:response].status %>
-
- <% if @result[:response].request_id.present? %> -
-
Request ID
-
<%= @result[:response].request_id %>
-
- <% end %> -
- <%= render "playground/shared/json_viewer", data: @result[:data] %> -
- <% when :error %> - <%= render "playground/shared/error_card", error: @result[:error] %> - <% end %> -
-
-
diff --git a/test/dummy/app/views/playground/idle_jobs/show.html.erb b/test/dummy/app/views/playground/idle_jobs/show.html.erb deleted file mode 100644 index fc9030d..0000000 --- a/test/dummy/app/views/playground/idle_jobs/show.html.erb +++ /dev/null @@ -1,76 +0,0 @@ -<% job_error = @job if @job.is_a?(OctaSpace::Error) %> -<% logs_error = @logs if @logs.is_a?(OctaSpace::Error) %> -<% job_data = @job.respond_to?(:data) ? @job.data : nil %> -<% logs_data = @logs.respond_to?(:data) ? @logs.data : nil %> - -
-
-
-

Idle Jobs

-

- octa_client.idle_jobs.find + octa_client.idle_jobs.logs — inspect a specific job by node_id and job_id. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> -
-
- -
- <%= form_with url: playground_idle_jobs_path, method: :get, local: true, class: "grid gap-4 md:grid-cols-[minmax(0,10rem)_minmax(0,10rem)_auto]" do %> -
- - <%= text_field_tag :node_id, @node_id, class: "w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 font-mono text-xs text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -
-
- - <%= text_field_tag :job_id, @job_id, class: "w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 font-mono text-xs text-slate-200 focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500" %> -
-
- <%= button_tag "Load Job", type: "submit", class: "rounded border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-400 transition-colors hover:bg-cyan-500/20 hover:text-cyan-300" %> -
- <% end %> -
- -
-
-
-
-

Status

- octa_client.idle_jobs.find(node_id: <%= @node_id %>, job_id: <%= @job_id %>) -
-
- - <% if params[:node_id].blank? || params[:job_id].blank? %> -
Enter a node ID and job ID to load the job.
- <% elsif job_error %> - <%= render "playground/shared/error_card", error: job_error, max_height: "max-h-56" %> - <% elsif job_data.present? %> - <%= render "playground/shared/json_viewer", data: job_data, max_height: "max-h-[28rem]" %> - <% else %> -
No job response available.
- <% end %> -
- -
-
-
-

Logs

- octa_client.idle_jobs.logs(node_id: <%= @node_id %>, job_id: <%= @job_id %>) -
-
- - <% if params[:node_id].blank? || params[:job_id].blank? %> -
Logs are loaded together with job details.
- <% elsif logs_error %> - <%= render "playground/shared/error_card", error: logs_error, max_height: "max-h-56" %> - <% elsif logs_data.present? %> - <%= render "playground/shared/json_viewer", data: logs_data, max_height: "max-h-[28rem]" %> - <% else %> -
No log response available.
- <% end %> -
-
-
diff --git a/test/dummy/app/views/playground/network/show.html.erb b/test/dummy/app/views/playground/network/show.html.erb deleted file mode 100644 index 8a3f51e..0000000 --- a/test/dummy/app/views/playground/network/show.html.erb +++ /dev/null @@ -1,80 +0,0 @@ -<% network_data = @network.respond_to?(:data) ? @network.data : nil %> -<% network_error = @network if @network.is_a?(OctaSpace::Error) %> -<% config_snapshot = { - urls: playground_config.urls, - mock_scenario: playground_mock_scenario, - open_timeout: playground_config.open_timeout, - read_timeout: playground_config.read_timeout, - write_timeout: playground_config.write_timeout, - keep_alive: playground_config.keep_alive?, - pool_size: playground_config.pool_size, - pool_timeout: playground_config.pool_timeout, - idle_timeout: playground_config.idle_timeout, - max_retries: playground_config.max_retries, - retry_interval: playground_config.retry_interval, - backoff_factor: playground_config.backoff_factor -} %> - -
-
-
-

Network Stats

- public - <% if playground_mock_active? %> - mock - <% end %> -
-

- Public endpoint — no API key required. Compare the current transport payload with the Ruby client configuration side by side. -

-
- -
-
-
-
-

Ruby SDK

- octa_client.network.info -
- <%= link_to "Refetch", playground_network_path, class: "rounded border border-slate-700 px-2.5 py-1 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- -
-
status: <%= @network.respond_to?(:status) ? @network.status : "—" %>
-
request_id: <%= @network.respond_to?(:request_id) ? @network.request_id || "—" : "—" %>
-
- - <% if network_error %> - <%= render "playground/shared/error_card", error: network_error %> - <% elsif network_data %> - <%= render "playground/shared/json_viewer", data: network_data %> - <% else %> -
No network payload available.
- <% end %> -
- -
-
-
-

Transport

- <%= playground_transport_label %> -
- <%= playground_transport_stats[:mode] %> -
- -
-
description: <%= playground_transport_description %>
-
timeouts: <%= playground_timeout_label %>
-
keep_alive: <%= playground_config.keep_alive? %>
-
max_retries: <%= playground_config.max_retries %>
-
- - <%= render "playground/shared/json_viewer", data: config_snapshot %> -
-
- -
-
Client Configuration
- <%= render "playground/shared/json_viewer", data: config_snapshot, max_height: "max-h-24" %> -
-
diff --git a/test/dummy/app/views/playground/nodes/index.html.erb b/test/dummy/app/views/playground/nodes/index.html.erb deleted file mode 100644 index b27d941..0000000 --- a/test/dummy/app/views/playground/nodes/index.html.erb +++ /dev/null @@ -1,83 +0,0 @@ -<% nodes_data = @nodes.respond_to?(:data) ? Array(@nodes.data) : [] %> -<% nodes_error = @nodes if @nodes.is_a?(OctaSpace::Error) %> -<% selected_error = @selected_node if @selected_node.is_a?(OctaSpace::Error) %> -<% selected_node_data = @selected_node.respond_to?(:data) ? @selected_node.data : nil %> - -
-
-
-

Nodes

-

- octa_client.nodes.list + octa_client.nodes.find(id) — click a row to load detail. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% if nodes_error %> - <%= render "playground/shared/error_card", error: nodes_error %> - <% elsif nodes_data.any? %> -
-
-
- <%= nodes_data.length %> nodes - <% if selected_node_data || selected_error %> - <%= link_to "Close detail", playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")), class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- -
- - - - - - - - - - - - - <% nodes_data.each do |node| %> - <% gpu_entries = Array(node.dig("system", "gpus") || node.dig("data", "gpus")) %> - <% gpu_count = gpu_entries.length %> - <% gpu_model = gpu_entries.first&.dig("model") || node.dig("system", "gpu") || node.dig("system", "gpu_model") || "—" %> - <% ram_bytes = node.dig("system", "memory", "size") || node.dig("system", "memory") || node.dig("data", "memory_size") || node.dig("system", "ram") %> - <% location = [node.dig("location", "city"), node.dig("location", "country")].compact.reject(&:blank?).join(", ") %> - <% selected = @selected_node_id.to_s == node["id"].to_s %> - '" class="cursor-pointer border-b border-slate-800 text-xs transition-colors hover:bg-slate-800/60 <%= selected ? 'bg-slate-800/80' : '' %>"> - - - - - - - - <% end %> - -
IDLocationCPUGPURAMState
- <%= node["id"] %> - <%= location.presence || "—" %><%= node.dig("system", "cpu_cores") || node.dig("data", "cpu_cores") || "—" %> cores<%= gpu_count.positive? ? "#{gpu_count}× " : "" %><%= gpu_model %><%= ram_bytes.is_a?(Numeric) && ram_bytes > 1024 ? "#{(ram_bytes.to_f / 1024 / 1024 / 1024).round} GB" : (ram_bytes.present? ? "#{ram_bytes} GB" : "—") %> - "><%= node["state"] || "unknown" %> -
-
-
- - <% if selected_error %> - <%= render "playground/shared/error_card", error: selected_error %> - <% elsif selected_node_data %> - <%= render "playground/shared/node_detail_panel", - node: selected_node_data, - close_path: playground_nodes_path(request.query_parameters.except("selected_id", "node_tab")) %> - <% end %> -
- <% else %> -
No nodes found.
- <% end %> -
diff --git a/test/dummy/app/views/playground/nodes/show.html.erb b/test/dummy/app/views/playground/nodes/show.html.erb deleted file mode 100644 index fa51e54..0000000 --- a/test/dummy/app/views/playground/nodes/show.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<% node_data = @node.respond_to?(:data) ? @node.data : nil %> -<% node_error = @node if @node.is_a?(OctaSpace::Error) %> - -
-
-
-

Nodes

-

- Direct node view for octa_client.nodes.find(<%= params[:id] %>). -

-
- <%= link_to "Back to list", playground_nodes_path(selected_id: params[:id]), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
- - <% if node_error %> - <%= render "playground/shared/error_card", error: node_error %> - <% elsif node_data %> - <%= render "playground/shared/node_detail_panel", node: node_data, node_id: params[:id], close_path: playground_nodes_path %> - <% else %> -
Node payload unavailable.
- <% end %> -
diff --git a/test/dummy/app/views/playground/services/show.html.erb b/test/dummy/app/views/playground/services/show.html.erb deleted file mode 100644 index 7e91cf3..0000000 --- a/test/dummy/app/views/playground/services/show.html.erb +++ /dev/null @@ -1,134 +0,0 @@ -<% mr_data = @mr_catalog.respond_to?(:data) ? Array(@mr_catalog.data) : [] %> -<% render_data = @render_catalog.respond_to?(:data) ? Array(@render_catalog.data) : [] %> -<% vpn_data = @vpn_catalog.respond_to?(:data) ? Array(@vpn_catalog.data) : [] %> -<% tabs = [ - {id: "mr", label: "Machine Rental Marketplace", hook: "octa_client.services.mr.list", path: "/services/mr", data: mr_data, error: (@mr_catalog if @mr_catalog.is_a?(OctaSpace::Error))}, - {id: "render", label: "Render Marketplace", hook: "octa_client.services.render.list", path: "/services/render", data: render_data, error: (@render_catalog if @render_catalog.is_a?(OctaSpace::Error))}, - {id: "vpn", label: "VPN Relay Marketplace", hook: "octa_client.services.vpn.list", path: "/services/vpn", data: vpn_data, error: (@vpn_catalog if @vpn_catalog.is_a?(OctaSpace::Error))} -] %> -<% active = tabs.find { |tab| tab[:id] == @active_tab } || tabs.first %> -<% active_status = active[:error] ? "error" : "success" %> -<% - offer_label = lambda do |tab_id, item| - case tab_id - when "vpn" - item["residential"] ? "Residential" : "Datacenter" - else - item["gpu"].presence || item["gpus"]&.first&.dig("model") || "CPU" - end - end - - pricing_label = lambda do |tab_id, item| - case tab_id - when "vpn" - usd = item["traffic_price_usd"] - ether = item["traffic_price_ether"] - parts = [] - parts << "$#{usd}/GB" if usd.present? - parts << "#{ether} ETH/GB" if ether.present? - parts.presence&.join(" · ") || "—" - else - base = item["base_usd"] || item["base"] - traffic = item["traffic_usd"] || item["traffic"] - parts = [] - parts << "$#{base}/hr" if base.present? - parts << "$#{traffic}/GB" if traffic.present? - parts.presence&.join(" · ") || "—" - end - end -%> - -
-
-
-

Services

-

- Marketplace catalog endpoints for MR, Render, and VPN. Uses the Ruby SDK client against the currently selected transport. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_services_path(request.query_parameters.merge(tab: @active_tab)), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- -
- Start mutations (mr.create, render.create, vpn.create) exist on octa_client.services.* but are intentionally not wired up here — use Diagnostics for manual request inspection. -
- -
- Bandwidth below is normalized for display with OctaSpace::PayloadHelpers.normalize_marketplace_bandwidth when live payload values look like raw bytes-per-second. Raw JSON remains unchanged below. -
- -
-
- <% tabs.each do |tab| %> - <%= link_to playground_services_path(request.query_parameters.merge(tab: tab[:id])), - class: "px-4 py-2.5 text-sm transition-colors #{@active_tab == tab[:id] ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px font-medium' : 'text-slate-500 hover:text-slate-300'}" do %> - <%= tab[:label] %> - <% end %> - <% end %> -
- -
-
- <%= active[:hook] %> - - status: - <%= active_status %> - -
- - <% if active[:error] %> - <%= render "playground/shared/error_card", error: active[:error] %> - <% else %> -
-
- <%= active[:data].is_a?(Array) ? "#{active[:data].length} items" : "#{active[:data].to_h.keys.length} keys" %> -
- <% if active[:data].is_a?(Array) && active[:data].any? %> -
-
Marketplace Summary
-
- - - - - - - - - - - - <% active[:data].each do |item| %> - - - - - - - - <% end %> - -
NodeLocationOfferBandwidth ↓/↑Pricing
#<%= item["node_id"] || "—" %><%= [item["city"], item["country"]].compact_blank.join(", ").presence || "—" %><%= offer_label.call(active[:id], item) %> - <%= playground_format_mbps(item["net_down_mbits"]) %> / <%= playground_format_mbps(item["net_up_mbits"]) %> - <%= pricing_label.call(active[:id], item) %>
-
-
- <% end %> -
- <%= render "playground/shared/json_viewer", data: active[:data], max_height: "max-h-[32rem]" %> -
-
- <% end %> -
-
- -
-
Request Reference
- <%= render "playground/shared/json_viewer", data: {method: "GET", path: active[:path], resource: active[:hook], transport: playground_transport_label}, max_height: "max-h-20" %> -
-
diff --git a/test/dummy/app/views/playground/sessions/index.html.erb b/test/dummy/app/views/playground/sessions/index.html.erb deleted file mode 100644 index b0e64cf..0000000 --- a/test/dummy/app/views/playground/sessions/index.html.erb +++ /dev/null @@ -1,138 +0,0 @@ -<% current_data = @current_sessions.respond_to?(:data) ? Array(@current_sessions.data) : nil %> -<% recent_data = @recent_sessions.respond_to?(:data) ? Array(@recent_sessions.data) : nil %> -<% current_error = @current_sessions if @current_sessions.is_a?(OctaSpace::Error) %> -<% recent_error = @recent_sessions if @recent_sessions.is_a?(OctaSpace::Error) %> -<% tabs = [ - {id: "current", label: "Current", description: "Active sessions"}, - {id: "recent", label: "Recent", description: "Last 50 completed + active"} -] %> - -
-
-
-

Sessions

-

- octa_client.sessions.list for discovery, with stop actions wired through octa_client.services.session(uuid).stop. -

-
-
- <% if playground_mock_active? %> - Mock: <%= playground_mock_scenario %> - <% end %> - <%= link_to "Refetch", playground_sessions_path(request.query_parameters.merge(tab: @active_tab)), class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> -
-
- - <% active_data = @active_tab == "recent" ? recent_data : current_data %> - <% active_error = @active_tab == "recent" ? recent_error : current_error %> - <% active_recent = @active_tab == "recent" %> - <% active_hook = active_recent ? "octa_client.sessions.list(recent: true)" : "octa_client.sessions.list" %> - <% base_query = request.query_parameters.except("stop_uuid") %> - <% stop_target = current_data&.find { |session| session["uuid"] == @stop_uuid } if @stop_uuid.present? %> - <% stop_notice = flash[:playground_stop_notice] %> - <% stop_error = flash[:playground_stop_error] %> - - <% if stop_notice.present? %> -
-
- -
-
Stop request submitted
-

<%= stop_notice["message"] %>

- <% if stop_notice["request_id"].present? %> -

request_id: <%= stop_notice["request_id"] %>

- <% end %> -
-
-
- <% end %> - - <% if stop_error.present? %> -
-
- -
-
Stop request failed
-

<%= stop_error %>

-
-
-
- <% end %> - - <% if active_recent %> -
- Recent session payloads may serialize telemetry like duration, rx, and tx as strings. For finished-session logs, use Diagnostics with services.session.logs and recent: true. -
- <% end %> - -
-
- <% tabs.each do |tab| %> - <%= link_to playground_sessions_path(request.query_parameters.merge(tab: tab[:id])), - class: "flex items-center gap-2 px-4 py-2.5 text-sm transition-colors #{@active_tab == tab[:id] ? '-mb-px border-b-2 border-cyan-500 font-medium text-cyan-400' : 'text-slate-500 hover:text-slate-300'}" do %> - <%= tab[:label] %> - <%= tab[:description] %> - <% end %> - <% end %> -
- -
-
- <%= active_hook %> - - status: - <%= active_error ? "error" : "success" %> - -
- - <% if active_error %> - <%= render "playground/shared/error_card", error: active_error %> - <% elsif active_data %> - <% if active_data.empty? %> -
- No <%= active_recent ? "recent" : "active" %> sessions found. -
- <% else %> -
-
- <%= active_data.length %> session<%= "s" unless active_data.length == 1 %> -
- <%= render "playground/shared/sessions_table", sessions: active_data, recent: active_recent %> -
- <% end %> - <% else %> -
- No response available. -
- <% end %> -
-
- - <% if active_data.present? && !active_error %> -
-
Raw JSON
- <%= render "playground/shared/json_viewer", data: active_data, max_height: "max-h-80" %> -
- <% end %> -
- -<% if stop_target.present? %> -
-
-

Stop session?

-

- This will stop session <%= stop_target["uuid"] %>. This action cannot be undone. -

-
- <%= link_to "Cancel", - playground_sessions_path(base_query.merge(tab: @active_tab)), - class: "rounded border border-slate-700 px-3 py-1.5 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:text-slate-200" %> - <%= form_with url: playground_sessions_stop_path, method: :post, local: true do %> - <%= hidden_field_tag :uuid, stop_target["uuid"] %> - <%= hidden_field_tag :tab, @active_tab %> - <%= button_tag "Stop session", type: "submit", class: "rounded bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-500" %> - <% end %> -
-
-
-<% end %> diff --git a/test/dummy/app/views/playground/shared/_error_card.html.erb b/test/dummy/app/views/playground/shared/_error_card.html.erb deleted file mode 100644 index f0c9f74..0000000 --- a/test/dummy/app/views/playground/shared/_error_card.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<% max_height = local_assigns.fetch(:max_height, "max-h-72") %> - -
-
- -
-
<%= error.class.name %>
-

<%= error.message.presence || "Request failed" %>

-
-
- -
-
-
Status
-
<%= error.status || "—" %>
-
-
-
Request ID
-
<%= error.request_id || "—" %>
-
-
-
Type
-
<%= error.class.name.demodulize %>
-
-
- - <%= render "playground/shared/json_viewer", data: playground_error_payload(error), max_height: max_height %> -
diff --git a/test/dummy/app/views/playground/shared/_json_viewer.html.erb b/test/dummy/app/views/playground/shared/_json_viewer.html.erb deleted file mode 100644 index 2dafa09..0000000 --- a/test/dummy/app/views/playground/shared/_json_viewer.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<% max_height = local_assigns.fetch(:max_height, "h-[42rem]") %> - -
- -
<%= playground_json_html(data) %>
-
diff --git a/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb b/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb deleted file mode 100644 index 3405e40..0000000 --- a/test/dummy/app/views/playground/shared/_node_detail_panel.html.erb +++ /dev/null @@ -1,58 +0,0 @@ -<% node_tab = request.query_parameters.fetch("node_tab", "summary") %> -<% node_id = node["id"] || local_assigns[:node_id] %> -<% gpu_entries = Array(node.dig("system", "gpus") || node.dig("data", "gpus")) %> -<% location = [node.dig("location", "city"), node.dig("location", "country")].compact.reject(&:blank?).join(", ") %> -<% ram_value = node.dig("system", "memory", "size") || node.dig("system", "memory") || node.dig("data", "memory_size") || node.dig("system", "ram") %> -<% formatted_ram = if ram_value.is_a?(Numeric) && ram_value > 1024 - "#{(ram_value.to_f / 1024 / 1024 / 1024).round} GB" -elsif ram_value.present? - "#{ram_value} GB" -else - "—" -end %> -<% rows = [ - ["State", node["state"] || node.dig("status") || "—"], - ["IP", node["ip"] || node["public_ip"] || "—"], - ["Location", location.presence || "—"], - ["CPU", node.dig("data", "cpu_model_name") || node.dig("system", "cpu_model_name") || node.dig("system", "cpu") || "—"], - ["Cores", node.dig("data", "cpu_cores") || node.dig("system", "cpu_cores") || "—"], - ["RAM", formatted_ram], - ["CUDA", node.dig("data", "cuda_version") || node.dig("system", "cuda_version") || "—"], - ["OS", node.dig("data", "os_version") || node.dig("system", "os") || "—"], - ["Driver", node.dig("data", "gpu_driver_version") || node.dig("system", "gpu_driver_version") || "—"], - ["Uptime", playground_format_duration(node.dig("data", "uptime") || node["uptime"])], - ["GPU vendor", node.dig("data", "gpu_vendor") || gpu_entries.first&.dig("vendor") || "—"] -] %> - -
-
-
-

Node #<%= node_id %>

- octa_client.nodes.find(<%= node_id %>) -
- <% if local_assigns[:close_path].present? %> - <%= link_to "Close detail", close_path, class: "text-xs text-slate-500 transition-colors hover:text-slate-300" %> - <% end %> -
- -
- <% {"summary" => "Summary", "raw" => "Raw JSON"}.each do |value, label| %> - <%= link_to label, - playground_query_url(:node_tab, value), - class: "px-3 py-1.5 text-xs transition-colors #{node_tab == value ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px' : 'text-slate-500 hover:text-slate-300'}" %> - <% end %> -
- - <% if node_tab == "raw" %> - <%= render "playground/shared/json_viewer", data: node %> - <% else %> -
- <% rows.each do |label, value| %> -
- <%= label %> - <%= value %> -
- <% end %> -
- <% end %> -
diff --git a/test/dummy/app/views/playground/shared/_resource_panel.html.erb b/test/dummy/app/views/playground/shared/_resource_panel.html.erb deleted file mode 100644 index 3577417..0000000 --- a/test/dummy/app/views/playground/shared/_resource_panel.html.erb +++ /dev/null @@ -1,52 +0,0 @@ -<% tab_param = local_assigns.fetch(:tab_param, :tab).to_s %> -<% current_tab = request.query_parameters.fetch(tab_param, local_assigns.fetch(:default_tab, "formatted")) %> -<% locked = local_assigns.fetch(:locked, false) %> -<% error = local_assigns[:error] %> -<% empty_copy = local_assigns.fetch(:empty_copy, "No data available.") %> -<% locked_copy = local_assigns.fetch(:locked_copy, "Enter an API key in the sidebar to load this resource") %> -<% badge = local_assigns[:badge] %> -<% has_data = playground_present?(data) %> - -
-
-
-

<%= title %>

- <% if hook.present? %> - <%= hook %> - <% end %> -
- - <% if badge.present? %> - <%= badge %> - <% end %> -
- - <% if locked %> -
- - - - <%= locked_copy %> -
- <% elsif error.present? %> - <%= render "playground/shared/error_card", error: error %> - <% elsif has_data %> -
- <% {"formatted" => "Formatted", "raw" => "Raw JSON"}.each do |value, label| %> - <%= link_to label, - playground_query_url(tab_param, value), - class: "px-3 py-1.5 text-xs transition-colors #{current_tab == value ? 'border-b-2 border-cyan-500 text-cyan-400 -mb-px' : 'text-slate-500 hover:text-slate-300'}" %> - <% end %> -
- - <% if current_tab == "raw" %> - <%= render "playground/shared/json_viewer", data: data, max_height: local_assigns.fetch(:raw_max_height, "h-[42rem]") %> - <% else %> - <%= formatted %> - <% end %> - <% else %> -
- <%= empty_copy %> -
- <% end %> -
diff --git a/test/dummy/app/views/playground/shared/_sessions_table.html.erb b/test/dummy/app/views/playground/shared/_sessions_table.html.erb deleted file mode 100644 index 90b5a80..0000000 --- a/test/dummy/app/views/playground/shared/_sessions_table.html.erb +++ /dev/null @@ -1,102 +0,0 @@ -<% - service_label = lambda do |service| - case service.to_s - when "mr" then "RENTAL" - when "render" then "RENDER" - when "vpn" then "VPN" - else "—" - end - end - - service_badge_class = lambda do |service| - case service.to_s - when "mr" then "border border-sky-500/30 bg-sky-900/30 text-sky-400" - when "render" then "border border-orange-500/30 bg-orange-900/30 text-orange-400" - when "vpn" then "border border-indigo-500/30 bg-indigo-900/30 text-indigo-400" - else "border border-slate-600/40 bg-slate-700/50 text-slate-400" - end - end - - normal_reason = lambda do |reason| - [0, 1].include?(reason.to_i) - end - - session_status = lambda do |session, recent_mode| - if recent_mode && session["termination_reason"].present? - normal_reason.call(session["termination_reason"]) ? "Normal" : "Error" - elsif session["is_ready"] - "Ready" - elsif session["progress"].present? - session["progress"] - else - session["status"].presence || "Starting" - end - end - - status_badge_class = lambda do |session, recent_mode| - if recent_mode && session["termination_reason"].present? - normal_reason.call(session["termination_reason"]) ? "border border-emerald-500/30 bg-emerald-500/15 text-emerald-400" : "border border-rose-500/30 bg-rose-500/15 text-rose-400" - elsif session["is_ready"] - "border border-emerald-500/30 bg-emerald-500/15 text-emerald-400" - else - "border border-amber-500/30 bg-amber-500/15 text-amber-400" - end - end -%> - -<% show_service = sessions.any? { |s| s["service"].present? } %> - -
- - - - - - <% if show_service %> - - <% end %> - - - - - <% unless recent %> - - <% end %> - - <% unless recent %> - - <% end %> - - - - <% sessions.each do |session| %> - - - - <% if show_service %> - - <% end %> - - - - - <% unless recent %> - - <% end %> - - <% unless recent %> - - <% end %> - - <% end %> - -
SessionNode IDServiceStatusStartedDurationTraffic ↓/↑IPAmountAction
"><%= playground_truncate(session["uuid"]) %>#<%= session["node_id"] || "—" %> - "><%= service_label.call(session["service"]) %> - - <%= session_status.call(session, recent) %> - <%= playground_format_datetime(session["started_at"]) %><%= playground_format_duration(session["duration"]) %><%= playground_format_bytes(session["rx"]) %> / <%= playground_format_bytes(session["tx"]) %><%= session["public_ip"] || session["ip"] || "—" %><%= session["charge_amount"] || "—" %> - <%= link_to "Stop", - playground_sessions_path(request.query_parameters.except("stop_uuid").merge(tab: "current", stop_uuid: session["uuid"])), - class: "rounded border border-red-800/50 px-2 py-0.5 text-[11px] text-red-500 transition-colors hover:border-red-600 hover:text-red-400" %> -
-
diff --git a/test/dummy/bin/rails b/test/dummy/bin/rails deleted file mode 100755 index 22f2d8d..0000000 --- a/test/dummy/bin/rails +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -APP_PATH = File.expand_path("../config/application", __dir__) -require_relative "../config/boot" -require "rails/commands" diff --git a/test/dummy/config.ru b/test/dummy/config.ru deleted file mode 100644 index 2797095..0000000 --- a/test/dummy/config.ru +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require_relative "config/environment" - -run Rails.application -Rails.application.load_server diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb deleted file mode 100644 index 68849f7..0000000 --- a/test/dummy/config/application.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require_relative "boot" - -require "rails" -require "action_controller/railtie" -require "action_view/railtie" - -# Load the gem itself -$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) -require "octaspace" - -module Dummy - class Application < Rails::Application - config.root = File.expand_path("..", __dir__) - config.load_defaults [Rails::VERSION::MAJOR, Rails::VERSION::MINOR].join(".") - config.eager_load = false - config.secret_key_base = "dummy_secret_key_base_for_playground_only" - config.logger = Logger.new($stdout) - config.log_level = :info - end -end diff --git a/test/dummy/config/boot.rb b/test/dummy/config/boot.rb deleted file mode 100644 index 7865da2..0000000 --- a/test/dummy/config/boot.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) - -require "bundler/setup" diff --git a/test/dummy/config/environment.rb b/test/dummy/config/environment.rb deleted file mode 100644 index e8173e0..0000000 --- a/test/dummy/config/environment.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -require_relative "application" - -Rails.application.initialize! diff --git a/test/dummy/config/initializers/octaspace.rb b/test/dummy/config/initializers/octaspace.rb deleted file mode 100644 index 7f68330..0000000 --- a/test/dummy/config/initializers/octaspace.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -OctaSpace.configure do |config| - config.api_key = ENV.fetch("OCTA_API_KEY", nil) - config.keep_alive = ENV["OCTASPACE_KEEP_ALIVE"] == "true" - config.pool_size = ENV.fetch("RAILS_MAX_THREADS", 5).to_i - # Keep the playground responsive by default; override with OCTASPACE_MAX_RETRIES if needed. - config.max_retries = ENV.fetch("OCTASPACE_MAX_RETRIES", 0).to_i - config.logger = Rails.logger -end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb deleted file mode 100644 index c1a075d..0000000 --- a/test/dummy/config/routes.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -Rails.application.routes.draw do - scope path: "playground", as: "playground", module: "playground" do - get "dashboard", to: "dashboard#show" - get "network", to: "network#show" - get "account", to: "accounts#show" - get "apps", to: "apps#show" - get "nodes", to: "nodes#index" - get "nodes/:id", to: "nodes#show", as: :node - get "sessions", to: "sessions#index" - post "sessions/stop", to: "sessions#stop", as: :sessions_stop - get "services", to: "services#show" - get "idle-jobs", to: "idle_jobs#show", as: :idle_jobs - get "diagnostics", to: "diagnostics#show" - post "diagnostics/run", to: "diagnostics#run", as: :diagnostics_run - post "diagnostics/smoke", to: "diagnostics#smoke", as: :diagnostics_smoke - patch "settings", to: "settings#update", as: :settings - delete "settings", to: "settings#destroy" - delete "request-log", to: "request_logs#destroy", as: :request_log - end - - root to: redirect("/playground/dashboard") -end diff --git a/test/dummy/public/playground.css b/test/dummy/public/playground.css deleted file mode 100644 index 5b28ece..0000000 --- a/test/dummy/public/playground.css +++ /dev/null @@ -1,38 +0,0 @@ -.json-key { color: rgb(148 163 184); } -.json-string { color: rgb(34 211 238); } -.json-number { color: rgb(251 191 36); } -.json-bool { color: rgb(129 140 248); } -.json-null { color: rgb(248 113 113); } - -::-webkit-scrollbar { width: 8px; height: 8px; } -::-webkit-scrollbar-track { background: rgb(15 23 42); } -::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 9999px; } -::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); } - -/* Page loader ---------------------------------------------------------------- - Visibility is controlled from JS after 0.4 s, so fast responses never - produce a visible flash. */ -#page-loader { - display: none; - position: absolute; - inset: 0; - z-index: 20; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(2, 6, 23, 0.78); -} -#page-loader.loader-active { - display: flex; - opacity: 1; -} - -.loader-spinner { - width: 28px; - height: 28px; - border: 2.5px solid rgba(148, 163, 184, 0.18); - border-top-color: rgb(34, 211, 238); - border-radius: 50%; - animation: loader-spin 0.7s linear infinite; -} -@keyframes loader-spin { to { transform: rotate(360deg); } } diff --git a/test/dummy/public/playground.js b/test/dummy/public/playground.js deleted file mode 100644 index c5133ce..0000000 --- a/test/dummy/public/playground.js +++ /dev/null @@ -1,145 +0,0 @@ -document.addEventListener("click", async (event) => { - const button = event.target.closest("[data-copy-json]") - if (!button) return - - const viewer = button.closest("[data-json-viewer]") - const pre = viewer && viewer.querySelector("pre") - if (!pre) return - - const originalLabel = button.dataset.label || button.textContent || "Copy" - - try { - await navigator.clipboard.writeText(pre.textContent || "") - button.textContent = button.dataset.copiedLabel || "Copied!" - } catch (error) { - button.textContent = "Copy failed" - } - - button.disabled = true - window.setTimeout(() => { - button.textContent = originalLabel - button.disabled = false - }, 1500) -}) - -document.addEventListener("change", (event) => { - const field = event.target.closest("[data-autosubmit-setting]") - if (!field || !field.form) return - - const initialValue = field.dataset.initialValue ?? "" - const currentValue = field.value ?? "" - if (currentValue === initialValue) return - - field.form.requestSubmit() -}) - -// Page loader: appears only after 400 ms of waiting, so fast responses do not flash. -const LOADER_SKIP = ["/playground/settings", "/playground/request-log"] -const LOADER_DELAY_MS = 400 -let loaderTimer = null - -const activateLoader = () => { - const element = document.getElementById("page-loader") - if (!element) return - - element.classList.add("loader-active") - element.setAttribute("aria-hidden", "false") -} - -const scheduleLoader = () => { - if (loaderTimer !== null) return - - loaderTimer = window.setTimeout(() => { - loaderTimer = null - activateLoader() - }, LOADER_DELAY_MS) -} - -const hideLoader = () => { - if (loaderTimer !== null) { - window.clearTimeout(loaderTimer) - loaderTimer = null - } - - const element = document.getElementById("page-loader") - if (!element) return - - element.classList.remove("loader-active") - element.setAttribute("aria-hidden", "true") -} - -document.addEventListener("submit", (event) => { - const form = event.target - if (!form || !form.action) return - - try { - const pathname = new URL(form.action, window.location.href).pathname - if (LOADER_SKIP.some((value) => pathname === value)) return - } catch (_error) { - return - } - - scheduleLoader() -}) - -document.addEventListener("click", (event) => { - if (!event.target.closest("[data-scroll-target]")) { - if (!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) { - const link = event.target.closest("a[href]") - - if (link) { - const href = link.getAttribute("href") - - if (href && !href.startsWith("#") && !href.startsWith("mailto:") && link.target !== "_blank") { - try { - const url = new URL(href, window.location.href) - if (url.origin === window.location.origin) { - scheduleLoader() - } - } catch (_error) { - } - } - } - } - } - - const trigger = event.target.closest("[data-scroll-target]") - if (!trigger) return - - const targetId = trigger.dataset.scrollTarget - if (!targetId) return - - const target = document.getElementById(targetId) - if (!target) return - - event.preventDefault() - target.scrollIntoView({behavior: "smooth", block: "start"}) - - if (typeof target.focus === "function") { - target.focus({preventScroll: true}) - } -}) - -const syncDiagnosticsSelection = () => { - const list = document.querySelector("[data-diagnostics-list]") - const selected = document.querySelector("[data-diagnostics-item][data-selected='true']") - if (!list || !selected) return - - window.requestAnimationFrame(() => { - selected.scrollIntoView({block: "nearest", inline: "nearest"}) - }) -} - -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", syncDiagnosticsSelection, {once: true}) -} else { - syncDiagnosticsSelection() -} - -window.addEventListener("pageshow", (event) => { - if (event.persisted) { - hideLoader() - } -}) - -window.addEventListener("pagehide", hideLoader) diff --git a/test/dummy/public/service-worker.js b/test/dummy/public/service-worker.js deleted file mode 100644 index 0796f5d..0000000 --- a/test/dummy/public/service-worker.js +++ /dev/null @@ -1 +0,0 @@ -// Intentionally empty: the playground does not register a service worker. \ No newline at end of file diff --git a/test/dummy_diagnostics_test.rb b/test/dummy_diagnostics_test.rb deleted file mode 100644 index 912db61..0000000 --- a/test/dummy_diagnostics_test.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require_relative "dummy/config/environment" - -class DummyDiagnosticsTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - end - - def test_diagnostics_run_renders_error_card_for_mock_scenario - @session.patch("/playground/settings", params: {mock_scenario: "401", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "network.info"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "401 Unauthorized" - assert_includes @session.response.body, "OctaSpace::AuthenticationError" - end - - def test_diagnostics_run_executes_vpn_create_mutation - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.vpn.create").merge(node_id: 77) - stub_request(:post, "https://api.octa.space/services/vpn") - .with(body: payload.to_json) - .to_return(status: 201, body: '{"uuid":"vpn-new-123","status":"starting"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "vpn-new-123" - end - - def test_diagnostics_run_executes_session_stop_mutation - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.stop") - stub_request(:post, "https://api.octa.space/services/sess-abc-123/stop") - .with(body: {score: payload[:score]}.to_json) - .to_return(status: 200, body: '{"status":"stopped"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.session.stop", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "stopped" - end - - def test_diagnostics_run_executes_recent_session_logs_call - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") - stub_request(:get, "https://api.octa.space/services/sess-abc-123/logs") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("services/session/logs.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.session.logs", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Success" - assert_includes @session.response.body, "octa_client.services.session(uuid).logs" - assert_includes @session.response.body, "services/sess-abc-123/logs?recent=true" - assert_includes @session.response.body, "Container started" - end - - def test_diagnostics_run_reports_invalid_payload_json - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "services.vpn.create", payload_json: "{not json}"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "OctaSpace::ValidationError" - assert_includes @session.response.body, "Invalid payload JSON" - end - - def test_diagnostics_smoke_runs_read_only_sdk_smoke_and_renders_json - stub_request(:get, "https://api.octa.space/network") - .to_return(status: 200, body: fixture("network/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/accounts") - .to_return(status: 200, body: fixture("accounts/show.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/accounts/balance") - .to_return(status: 200, body: fixture("accounts/balance.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/nodes") - .to_return(status: 200, body: fixture("nodes/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/mr") - .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/render") - .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/vpn") - .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/recent.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/smoke", params: {call: "network.info"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "SDK Smoke passed" - assert_includes @session.response.body, "sdk_smoke" - assert_includes @session.response.body, "octa_client.sessions.list(recent: true)" - end - - def test_diagnostics_run_executes_idle_job_find - payload = OctaSpace::Playground::PayloadPresets.payload_for("idle_jobs.find") - stub_request(:get, "https://api.octa.space/idle_jobs/69/42") - .to_return(status: 200, body: fixture("idle_jobs/show.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/diagnostics"}) - @session.post("/playground/diagnostics/run", params: {call: "idle_jobs.find", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "completed" - assert_includes @session.response.body, "octa_client.idle_jobs.find" - end - - def test_diagnostics_show_prefills_payload_from_query_string - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge( - app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c", - image: "ubuntu:24.04" - ) - - @session.get("/playground/diagnostics", params: {call: "services.mr.create", payload_json: JSON.pretty_generate(payload)}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "services.mr.create" - assert_includes @session.response.body, "249b4cb3-3db1-4c06-98a4-772ba88cd81c" - assert_includes @session.response.body, "ubuntu:24.04" - end - - def test_diagnostics_show_marks_selected_card_for_scroll_restoration - @session.get("/playground/diagnostics", params: {call: "services.session.logs"}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "data-diagnostics-list" - assert_includes @session.response.body, "data-diagnostics-item" - assert_includes @session.response.body, 'data-selected="true"' - assert_includes @session.response.body, "octa_client.services.session(uuid).logs" - end -end diff --git a/test/dummy_resources_test.rb b/test/dummy_resources_test.rb deleted file mode 100644 index 04330b3..0000000 --- a/test/dummy_resources_test.rb +++ /dev/null @@ -1,76 +0,0 @@ -# frozen_string_literal: true - -require "cgi" -require "test_helper" -require_relative "dummy/config/environment" - -class DummyResourcesTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/dashboard"}) - end - - def test_apps_page_renders_app_catalog - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/apps") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Apps" - assert_includes @session.response.body, "Stable Diffusion" - assert_includes @session.response.body, "Use in MR Create" - assert_includes @session.response.body, CGI.escape("249b4cb3-3db1-4c06-98a4-772ba88cd81c") - assert_includes @session.response.body, CGI.escape("ubuntu:24.04") - assert_includes @session.response.body, 'data-scroll-target="apps-raw-json"' - assert_includes @session.response.body, 'id="apps-raw-json"' - end - - def test_layout_brand_link_points_to_root - stub_request(:get, "https://api.octa.space/apps") - .to_return(status: 200, body: fixture("apps/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/apps") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, 'href="/"' - assert_includes @session.response.body, "SDK Playground" - assert_includes @session.response.body, 'id="page-loader"' - assert_includes @session.response.body, "Loading…" - end - - def test_idle_jobs_page_renders_status_and_logs - stub_request(:get, "https://api.octa.space/idle_jobs/69/42") - .to_return(status: 200, body: fixture("idle_jobs/show.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/idle_jobs/69/42/logs") - .to_return(status: 200, body: fixture("idle_jobs/logs.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/idle-jobs", params: {node_id: 69, job_id: 42}) - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Idle Jobs" - assert_includes @session.response.body, "Training complete." - assert_includes @session.response.body, "completed" - end - - def test_services_page_renders_marketplace_summary - stub_request(:get, "https://api.octa.space/services/mr") - .to_return(status: 200, body: fixture("services/mr/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/render") - .to_return(status: 200, body: fixture("services/render/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/services/vpn") - .to_return(status: 200, body: fixture("services/vpn/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.get("/playground/services") - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Machine Rental Marketplace" - assert_includes @session.response.body, "Marketplace Summary" - assert_includes @session.response.body, "6977.8 Mbps" - end -end diff --git a/test/dummy_sessions_test.rb b/test/dummy_sessions_test.rb deleted file mode 100644 index 2711240..0000000 --- a/test/dummy_sessions_test.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require_relative "dummy/config/environment" - -class DummySessionsTest < Minitest::Test - include FixtureHelpers - - def setup - super - ActionController::Base.allow_forgery_protection = false - @session = ActionDispatch::Integration::Session.new(Rails.application) - @session.host! "127.0.0.1" - end - - def test_sessions_stop_posts_stop_request_and_renders_notice - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:post, "https://api.octa.space/services/sess-abc-123/stop") - .to_return(status: 200, body: '{"status":"stopped"}', headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/sessions"}) - @session.post("/playground/sessions/stop", params: {uuid: "sess-abc-123", tab: "current"}) - - assert_equal 303, @session.response.status - assert_equal "http://127.0.0.1/playground/sessions?tab=current", @session.response.location - - @session.follow_redirect! - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Stop request submitted" - assert_includes @session.response.body, "sess-abc-123" - end - - def test_sessions_stop_without_uuid_redirects_with_error - stub_request(:get, "https://api.octa.space/sessions") - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - stub_request(:get, "https://api.octa.space/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/index.json"), headers: {"Content-Type" => "application/json"}) - - @session.patch("/playground/settings", params: {api_key: "test_key", mock_scenario: "real", return_to: "/playground/sessions"}) - @session.post("/playground/sessions/stop", params: {uuid: "", tab: "current"}) - - assert_equal 303, @session.response.status - @session.follow_redirect! - - assert_equal 200, @session.response.status - assert_includes @session.response.body, "Stop request failed" - assert_includes @session.response.body, "Session UUID is required" - end -end diff --git a/test/octaspace/client_test.rb b/test/octaspace/client_test.rb index 713f725..6b25a0d 100644 --- a/test/octaspace/client_test.rb +++ b/test/octaspace/client_test.rb @@ -57,11 +57,9 @@ def test_transport_stats_returns_hash end def test_accepts_injected_transport - config = OctaSpace::Configuration.new - transport = OctaSpace::Transport::MockTransport.new(config, scenario: "mock", delay_seconds: 0) + transport = Object.new client = OctaSpace::Client.new(api_key: "key", transport: transport) assert_same transport, client.instance_variable_get(:@transport) - assert_equal :mock, client.transport_stats[:mode] end end diff --git a/test/octaspace/playground/payload_presets_test.rb b/test/octaspace/playground/payload_presets_test.rb deleted file mode 100644 index 1753cdb..0000000 --- a/test/octaspace/playground/payload_presets_test.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "octaspace/playground/payload_presets" - -class OctaSpace::Playground::PayloadPresetsTest < Minitest::Test - def setup - super - OctaSpace::Playground::PayloadPresets.reset! - end - - def test_mr_preset_matches_live_script_contract - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") - - assert_equal 10, payload[:disk_size] - assert_equal "ubuntu:24.04", payload[:image] - assert_match(/\A[0-9a-f-]{36}\z/, payload[:app]) - end - - def test_payload_json_for_returns_pretty_json_object - json = OctaSpace::Playground::PayloadPresets.payload_json_for("services.session.stop") - - assert_includes json, "\"uuid\"" - assert_equal({"uuid" => "sess-abc-123", "score" => 5}, JSON.parse(json)) - end - - def test_logs_preset_defaults_to_recent_finished_session_flow - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.logs") - - assert_equal "sess-abc-123", payload[:uuid] - assert_equal true, payload[:recent] - end -end diff --git a/test/octaspace/playground/smoke_runner_test.rb b/test/octaspace/playground/smoke_runner_test.rb deleted file mode 100644 index 344f223..0000000 --- a/test/octaspace/playground/smoke_runner_test.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" -require "octaspace/playground/smoke_runner" - -class OctaSpace::Playground::SmokeRunnerTest < Minitest::Test - def setup - super - @client = test_client - end - - def test_run_returns_passed_summary_for_read_only_suites - stub_get("/network", fixture_path: "network/index.json") - stub_get("/accounts", fixture_path: "accounts/show.json") - stub_get("/accounts/balance", fixture_path: "accounts/balance.json") - stub_get("/apps", fixture_path: "apps/index.json") - stub_get("/nodes", fixture_path: "nodes/index.json") - stub_get("/services/mr", fixture_path: "services/mr/index.json") - stub_get("/services/render", fixture_path: "services/render/index.json") - stub_get("/services/vpn", fixture_path: "services/vpn/index.json") - stub_get("/sessions", fixture_path: "sessions/index.json") - stub_request(:get, "#{StubHelpers::BASE_URL}/sessions") - .with(query: {"recent" => "true"}) - .to_return(status: 200, body: fixture("sessions/recent.json"), headers: json_headers) - - result = OctaSpace::Playground::SmokeRunner.new(client: @client).run - - assert_equal "sdk_smoke", result[:kind] - assert_equal "passed", result[:status] - assert_equal 10, result.dig(:summary, :total) - assert_equal 10, result.dig(:summary, :passed) - assert_equal 0, result.dig(:summary, :failed) - assert_equal "network.info", result[:suites].first[:id] - assert_equal "passed", result[:suites].first[:status] - end -end diff --git a/test/octaspace/resources/services_test.rb b/test/octaspace/resources/services_test.rb index 45fa89e..5ebfbe2 100644 --- a/test/octaspace/resources/services_test.rb +++ b/test/octaspace/resources/services_test.rb @@ -3,6 +3,14 @@ require "test_helper" class OctaSpace::ServicesResourceTest < Minitest::Test + # Request payloads from the removed playground presets. + PAYLOADS = { + "services.mr.create" => {node_id: 1, disk_size: 10, image: "ubuntu:24.04", app: "249b4cb3-3db1-4c06-98a4-772ba88cd81c"}, + "services.render.create" => {node_id: 1, disk_size: 100}, + "services.vpn.create" => {node_id: 1, subkind: "wg"}, + "services.session.stop" => {uuid: "sess-abc-123", score: 5} + }.freeze + def setup super @client = test_client @@ -34,7 +42,7 @@ def test_mr_list_returns_array end def test_mr_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create").merge( + payload = PAYLOADS.fetch("services.mr.create").merge( organization_id: 77, project_id: 88 ) @@ -63,7 +71,7 @@ def test_mr_create_returns_success end def test_mr_create_raises_api_error_on_400_object_rejection - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + payload = PAYLOADS.fetch("services.mr.create") stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") .to_return(status: 400, body: '{"message":"Node not found"}', headers: json_headers) @@ -71,7 +79,7 @@ def test_mr_create_raises_api_error_on_400_object_rejection end def test_mr_create_raises_provision_rejected_error_on_batch_rejection - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.mr.create") + payload = PAYLOADS.fetch("services.mr.create") stub_request(:post, "#{StubHelpers::BASE_URL}/services/mr") .to_return( status: 200, @@ -101,7 +109,7 @@ def test_vpn_list_returns_array end def test_vpn_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.vpn.create") + payload = PAYLOADS.fetch("services.vpn.create") stub_post("/services/vpn", status: 201, body: '{"uuid":"new-vpn","status":"starting"}') response = @client.services.vpn.create(**payload) assert response.success? @@ -117,7 +125,7 @@ def test_render_list_returns_success end def test_render_create_returns_success - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.render.create") + payload = PAYLOADS.fetch("services.render.create") stub_post("/services/render", status: 201, body: '{"uuid":"new-render","status":"queued"}') response = @client.services.render.create(**payload) assert response.success? @@ -152,7 +160,7 @@ def test_session_proxy_logs_with_recent_flag end def test_session_proxy_stop - payload = OctaSpace::Playground::PayloadPresets.payload_for("services.session.stop") + payload = PAYLOADS.fetch("services.session.stop") stub_post("/services/sess-abc-123/stop", status: 200, body: '{"status":"stopped"}') response = @client.services.session(payload[:uuid]).stop(score: payload[:score]) assert response.success? diff --git a/test/octaspace/transport/mock_transport_test.rb b/test/octaspace/transport/mock_transport_test.rb deleted file mode 100644 index 94d9ab0..0000000 --- a/test/octaspace/transport/mock_transport_test.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -class OctaSpace::MockTransportTest < Minitest::Test - def setup - @config = OctaSpace::Configuration.new - @config.api_key = "test_key" - @transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "slow", delay_seconds: 0) - end - - def test_returns_mock_body_for_slow_scenario - response = @transport.get("/nodes") - - assert_equal 200, response.status - assert_equal "slow", response.data["scenario"] - assert_equal "Mocked 200", response.data["error"] - end - - def test_raises_typed_http_errors - transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "429", delay_seconds: 0) - error = assert_raises(OctaSpace::RateLimitError) { transport.get("/nodes") } - - assert_equal 429, error.status - assert_equal 60, error.retry_after - end - - def test_raises_timeout_error_for_timeout_scenario - transport = OctaSpace::Transport::MockTransport.new(@config, scenario: "timeout", delay_seconds: 0) - - error = assert_raises(OctaSpace::TimeoutError) { transport.get("/nodes") } - assert_match(/Mocked timeout/, error.message) - end - - def test_exposes_mock_transport_stats - assert_equal :mock, @transport.transport_stats[:mode] - assert_equal "slow", @transport.transport_stats[:scenario] - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb index bf1c5be..be68989 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,10 +3,7 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "octaspace" -require "octaspace/transport/mock_transport" -require "octaspace/playground/payload_presets" require "minitest/autorun" -require "minitest/mock" require "webmock/minitest" require_relative "support/fixture_helpers" From 1ef54a03d5fac25438b7d756ba49151ebd9deb63 Mon Sep 17 00:00:00 2001 From: secretpray Date: Tue, 3 Nov 2026 13:05:52 +0200 Subject: [PATCH 2/2] docs: translate the next-branch workflow guide to English --- docs/GIT_WORKFLOW_NEXT.md | 135 +++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/docs/GIT_WORKFLOW_NEXT.md b/docs/GIT_WORKFLOW_NEXT.md index 11a260d..10d9513 100644 --- a/docs/GIT_WORKFLOW_NEXT.md +++ b/docs/GIT_WORKFLOW_NEXT.md @@ -1,87 +1,86 @@ -# Работа с веткой `next` +# Working with the `next` branch -`next` — ветка перехода SDK на новое API OctaSpace (стенд `https://api-dev.octa.space`). -Все правки под новое API идут PR-ами в `next`; слияние `next → main` — отдельным решением, -когда новое API выйдет в прод. `main` остаётся веткой текущего (legacy) SDK и хотфиксов. -Так же устроены Cube, go-sdk, js-sdk и octa-cli. +`next` is the branch that moves the SDK to the new OctaSpace API (test stand `https://api-dev.octa.space`). +All changes for the new API go to `next` as pull requests; merging `next → main` is a separate decision, +made when the new API reaches production. `main` stays the branch of the current (legacy) SDK and its hotfixes. +Cube, go-sdk, js-sdk and octa-cli work the same way. -На GitHub ветка по умолчанию — **`main`**. Поэтому PR, открытый без явной базы, -уходит в `main`, а не в `next`. +The default branch on GitHub is **`main`**. A PR opened without an explicit base therefore targets `main`, +not `next`. -## `main` и `next` — по шагам +## `main` and `next`, step by step -| Шаг | Работа с `main` | Работа с `next` | +| Step | Working with `main` | Working with `next` | |---|---|---| -| Обновить ветки | `git fetch origin` | `git fetch origin` — то же | -| Перейти на базовую ветку | `git switch main` | `git switch next` | -| Подтянуть свежий код | `git pull` | `git pull` — то же (`next` отслеживает `origin/next`) | -| Новая ветка задачи | `git switch -c feat/x` (с `main`) | `git switch -c feat/x origin/next` ← **главное отличие** | -| Коммит | `git add …` → `git commit` | то же | -| Первый пуш ветки | `git push -u origin feat/x` | то же (пушится ветка задачи, не `next`) | -| Подтянуть свежую базу | `git rebase origin/main` | `git rebase origin/next` | -| PR через `gh` | `gh pr create` | `gh pr create --base next` ← **обязательно** | -| PR на сайте | база `main` подставится сама | вручную сменить **base: main → next** | -| Диф ветки против базы | `git diff main...HEAD` | `git diff origin/next...HEAD` | -| После слияния PR | `git switch main && git pull` | `git switch next && git pull` | - -`git switch -c feat/x origin/next` создаёт ветку задачи прямо от удалённой `next`, -даже если сейчас вы стоите на `main` и локальной `next` у вас нет. Ветка сразу -отслеживает `origin/next`; первый `git push -u origin feat/x` переключает её на -собственную удалённую ветку. - -## Типовой цикл задачи +| Update branches | `git fetch origin` | `git fetch origin` (same) | +| Switch to the base branch | `git switch main` | `git switch next` | +| Pull the latest code | `git pull` | `git pull` (same; `next` tracks `origin/next`) | +| New task branch | `git switch -c feat/x` (from `main`) | `git switch -c feat/x origin/next` ← **the key difference** | +| Commit | `git add …` → `git commit` | same | +| First push of the branch | `git push -u origin feat/x` | same (push the task branch, not `next`) | +| Catch up with the base | `git rebase origin/main` | `git rebase origin/next` | +| PR with `gh` | `gh pr create` | `gh pr create --base next` ← **required** | +| PR on the website | base `main` is preselected | change **base: main → next** by hand | +| Branch diff against the base | `git diff main...HEAD` | `git diff origin/next...HEAD` | +| After the PR is merged | `git switch main && git pull` | `git switch next && git pull` | + +`git switch -c feat/x origin/next` creates the task branch straight from the remote `next`, +even if you are on `main` and have no local `next`. The branch tracks `origin/next` at first; +the first `git push -u origin feat/x` switches it to its own remote branch. + +## Typical task cycle ```bash git fetch origin -git switch -c feat/my-task origin/next # от свежей next, даже если сейчас стоите на main -# ... правки ... +git switch -c feat/my-task origin/next # from a fresh next, even when you are on main +# ... changes ... bundle exec rake test && bundle exec standardrb git add -A && git commit -m "…" git push -u origin feat/my-task -gh pr create --base next # без --base PR откроется в main +gh pr create --base next # without --base the PR opens against main ``` -Если пока вы работали, в `next` что-то влили — обновить ветку перед PR: +If something was merged into `next` while you were working, update the branch before the PR: ```bash git fetch origin git rebase origin/next -git push --force-with-lease # после rebase обычный push откажет +git push --force-with-lease # a plain push is refused after a rebase ``` -## Зависимые PR (стек) +## Dependent PRs (a stack) -Если задача B строится на ещё не влитой задаче A, ветку B создают от A, а PR открывают -с базой A: `gh pr create --base feat/a`. Тогда в PR видна только разница B. +When task B builds on task A that is not merged yet, create branch B from A and open the PR +with base A: `gh pr create --base feat/a`. The PR then shows only B's changes. -⚠️ **Не удаляйте ветку A при слиянии** (`gh pr merge --delete-branch` или кнопка -«Delete branch»), пока на неё смотрит PR B: GitHub **закроет** PR B, а не перенацелит его. -Сливать стек по одному PR, в таком порядке: +⚠️ **Do not delete branch A when merging it** (`gh pr merge --delete-branch` or the +"Delete branch" button) while PR B points at it: GitHub **closes** PR B instead of retargeting it. +Merge the stack one PR at a time, in this order: ```bash -gh pr merge
--merge # без --delete-branch -gh pr edit --base next # перенацелить следующий PR -git push origin --delete feat/a # теперь ветку A можно удалить +gh pr merge --merge # without --delete-branch +gh pr edit --base next # retarget the next PR +git push origin --delete feat/a # now branch A can be deleted ``` -Если PR B всё же закрылся: восстановить ветку A на её последнем коммите -(`git push origin :refs/heads/feat/a`), затем `gh pr reopen ` и +If PR B was closed anyway: restore branch A at its last commit +(`git push origin :refs/heads/feat/a`), then `gh pr reopen ` and `gh pr edit --base next`. -## Ошибки, которые легко сделать - -1. **PR открылся в `main`.** Забыли `--base next` или не сменили base на сайте — - в PR вся разница `next` против `main`. - Исправить: `gh pr edit --base next`. -2. **Ветка создана от `main`.** `git switch -c feat/x`, стоя на `main`, даёт ветку - от `main`, и PR в `next` притащит лишние коммиты. Поэтому базу всегда указывать - явно (`origin/next`), а не полагаться на текущую ветку. - Исправить: `git rebase --onto origin/next origin/main feat/x`. -3. **Прямой push в `next`.** Пушить только ветки задач; в `next` — только через PR, - как и в `main`. -4. **Хотфиксы в `main` не доходят до `next`.** Срочные исправления legacy-SDK идут - веткой от `main` с PR в `main`. Чтобы они не потерялись, `main` периодически - вливается в `next` отдельным PR: +## Easy mistakes + +1. **The PR opened against `main`.** `--base next` was forgotten or the base was not changed on the + website, so the PR shows the whole difference between `next` and `main`. + Fix: `gh pr edit --base next`. +2. **The branch was created from `main`.** `git switch -c feat/x` while on `main` branches off `main`, + and a PR into `next` drags in extra commits. Always name the base explicitly (`origin/next`) + instead of relying on the current branch. + Fix: `git rebase --onto origin/next origin/main feat/x`. +3. **Pushing straight to `next`.** Push only task branches; changes reach `next` through PRs, + just like `main`. +4. **Hotfixes on `main` never reach `next`.** Urgent fixes to the legacy SDK go in a branch from + `main` with a PR into `main`. So that they are not lost, merge `main` into `next` from time to time + in a separate PR: ```bash git switch -c chore/sync-main-into-next origin/next @@ -90,18 +89,18 @@ git push origin --delete feat/a # теперь ветку A можно gh pr create --base next ``` -5. **Устаревшая локальная `next`.** Она обновляется только по `git pull`. Для новой - ветки и rebase надёжнее брать `origin/next` сразу после `git fetch`. +5. **A stale local `next`.** It only moves on `git pull`. For new branches and rebases it is safer + to use `origin/next` right after `git fetch`. -## CI и релизы +## CI and releases -- CI (`.github/workflows/ci.yml`) запускается на push в `main` и `next` и на любой PR, включая PR в стеке. -- Dependabot открывает PR обновлений в `next` (`target-branch` в `.github/dependabot.yml`). - После слияния `next → main` вернуть `target-branch: main`. -- Гем из `next` в RubyGems не публикуется, пока новое API не выйдет в прод. +- CI (`.github/workflows/ci.yml`) runs on pushes to `main` and `next` and on every PR, stacked ones included. +- Dependabot opens update PRs against `next` (`target-branch` in `.github/dependabot.yml`). + After `next` is merged into `main`, set `target-branch: main` again. +- The gem is not published to RubyGems from `next` until the new API reaches production. -## Если `next` — надолго +## If `next` lives for a long time -Можно переключить ветку по умолчанию на GitHub (Settings → Branches) на `next`: -PR будут открываться в неё сами, и ошибка 1 исчезнет. Это меняет базу по умолчанию -для всей команды, поэтому решается командой, а не одним разработчиком. +The default branch on GitHub (Settings → Branches) can be switched to `next`: PRs then target it +by default and mistake 1 goes away. This changes the default base for the whole team, so it is a +team decision, not one developer's.