mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
Compare commits
64 commits
hive@11.0.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b35f9fbc1f | ||
|
|
863f920b86 | ||
|
|
64e7fed1d4 | ||
|
|
a71b45bf76 | ||
|
|
730771fb50 | ||
|
|
5a85fb9dd6 | ||
|
|
d7e7025624 | ||
|
|
fe06ddec5a | ||
|
|
a237b4e782 | ||
|
|
5706e2de0e | ||
|
|
9c6989cd92 | ||
|
|
c46b2f2219 | ||
|
|
4a8bd4fd1b | ||
|
|
e3d9750cc9 | ||
|
|
7d4ef94432 | ||
|
|
fab4b03ace | ||
|
|
ed9ab34c70 | ||
|
|
f7a74ce419 | ||
|
|
2feec5d5db | ||
|
|
0162def432 | ||
|
|
c6905421e7 | ||
|
|
01184317c7 | ||
|
|
9708f71aa3 | ||
|
|
40fd27d9c0 | ||
|
|
77d6063512 | ||
|
|
1333fbcafa | ||
|
|
61394f30b0 | ||
|
|
0d81a7a01b | ||
|
|
2879316c05 | ||
|
|
dae36931f9 | ||
|
|
ca69b1c59f | ||
|
|
9ce8fda7f8 | ||
|
|
84043d9b3a | ||
|
|
615fb095d8 | ||
|
|
007dc4b8df | ||
|
|
f042e51bc3 | ||
|
|
742f50c52e | ||
|
|
73ba28d914 | ||
|
|
96bd390c7f | ||
|
|
34528114f3 | ||
|
|
fd0b8251c5 | ||
|
|
574a5d823e | ||
|
|
d3e0ef500f | ||
|
|
14858198e1 | ||
|
|
bd695d0f64 | ||
|
|
aa51ecd0de | ||
|
|
72cf5b8659 | ||
|
|
02f3ace50b | ||
|
|
12c990c2d8 | ||
|
|
883e0bcb02 | ||
|
|
aac23596ec | ||
|
|
e83bc29e2a | ||
|
|
7834a4d52a | ||
|
|
63805c8136 | ||
|
|
dc34f101e7 | ||
|
|
576c7558ba | ||
|
|
27d1d04e52 | ||
|
|
484054be27 | ||
|
|
4acd1c0446 | ||
|
|
70b3e19fe2 | ||
|
|
982d042b3b | ||
|
|
e5711a52a8 | ||
|
|
5bb70a63ac | ||
|
|
ebe8df69fb |
364 changed files with 12074 additions and 25797 deletions
|
|
@ -1,3 +0,0 @@
|
|||
# The following are aliases you can use with "cargo command_name"
|
||||
[alias]
|
||||
"clippy:fix" = "clippy --all --fix --allow-dirty --allow-staged"
|
||||
|
|
@ -65,14 +65,12 @@ module.exports = {
|
|||
parser: '@graphql-eslint/eslint-plugin',
|
||||
plugins: ['@graphql-eslint'],
|
||||
parserOptions: {
|
||||
graphQLConfig: {
|
||||
schema: SCHEMA_PATH,
|
||||
operations: OPERATIONS_PATHS,
|
||||
documents: OPERATIONS_PATHS,
|
||||
},
|
||||
schema: SCHEMA_PATH,
|
||||
operations: OPERATIONS_PATHS,
|
||||
},
|
||||
rules: {
|
||||
'@graphql-eslint/require-selections': 'error',
|
||||
'@graphql-eslint/no-deprecated': 'error',
|
||||
'@graphql-eslint/require-id-when-available': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -85,6 +83,7 @@ module.exports = {
|
|||
plugins: ['@graphql-eslint'],
|
||||
rules: {
|
||||
'@graphql-eslint/no-deprecated': 'error',
|
||||
'@graphql-eslint/require-id-when-available': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
169
.github/workflows/apollo-router-release.yaml
vendored
169
.github/workflows/apollo-router-release.yaml
vendored
|
|
@ -1,169 +0,0 @@
|
|||
name: apollo-router-release
|
||||
on:
|
||||
# For PRs, this pipeline will use the commit ID as Docker image tag and R2 artifact prefix.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'packages/libraries/router/**'
|
||||
- 'packages/libraries/sdk-rs/**'
|
||||
- 'docker/router.dockerfile'
|
||||
- 'scripts/compress/**'
|
||||
- 'configs/cargo/Cargo.lock'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
# For `main` changes, this pipeline will look for changes in Rust crates or plugin versioning, and
|
||||
# publish them only if changes are found and image does not exists in GH Packages.
|
||||
push:
|
||||
paths:
|
||||
- 'packages/libraries/router/**'
|
||||
- 'packages/libraries/sdk-rs/**'
|
||||
- 'docker/router.dockerfile'
|
||||
- 'scripts/compress/**'
|
||||
- 'configs/cargo/Cargo.lock'
|
||||
- 'Cargo.lock'
|
||||
- 'Cargo.toml'
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
# This script is doing the following:
|
||||
# 1. Get the version of the apollo-router and the plugin from the Cargo.toml and package.json files
|
||||
# 2. Check if there are changes in the Cargo.toml and package.json files in the current commit
|
||||
# 3. If there are changes, check if the image tag exists in the GitHub Container Registry
|
||||
find-changes:
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
outputs:
|
||||
should_release: ${{ steps.find_changes.outputs.should_release }}
|
||||
release_version: ${{ steps.find_changes.outputs.release_version }}
|
||||
release_latest: ${{ steps.find_changes.outputs.release_latest }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: find changes in versions
|
||||
id: find_changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
|
||||
echo "Running in a PR, using commit ID as tag"
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "release_latest=false" >> $GITHUB_OUTPUT
|
||||
echo "release_version=$GITHUB_SHA" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Running on push event, looking for changes in Rust crates or plugin versioning"
|
||||
|
||||
image_name="apollo-router"
|
||||
github_org="graphql-hive"
|
||||
router_version=$(cargo tree -i apollo-router --quiet | head -n 1 | awk -F" v" '{print $2}')
|
||||
plugin_version=$(jq -r '.version' packages/libraries/router/package.json)
|
||||
has_changes=$(git diff HEAD~ HEAD --name-only -- 'packages/libraries/router/Cargo.toml' 'packages/libraries/router/package.json' 'packages/libraries/sdk-rs/Cargo.toml' 'packages/libraries/sdk-rs/package.json' 'Cargo.lock' 'configs/cargo/Cargo.lock')
|
||||
|
||||
if [ "$has_changes" ]; then
|
||||
image_tag_version="router${router_version}-plugin${plugin_version}"
|
||||
|
||||
response=$(curl -L \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
-s \
|
||||
https://api.github.com/orgs/${github_org}/packages/container/${image_name}/versions)
|
||||
tag_exists=$(echo "$response" | jq -r ".[] | .metadata.container.tags[] | select(. | contains(\"${image_tag_version}\"))")
|
||||
|
||||
if [ ! "$tag_exists" ]; then
|
||||
echo "Found changes in version $version_to_publish"
|
||||
echo "release_version=$image_tag_version" >> $GITHUB_OUTPUT
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "release_latest=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changes found in version $image_tag_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Builds Rust crates, and creates Docker images
|
||||
dockerize:
|
||||
uses: ./.github/workflows/build-and-dockerize.yaml
|
||||
name: image-build
|
||||
needs:
|
||||
- find-changes
|
||||
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
|
||||
with:
|
||||
imageTag: ${{ needs.find-changes.outputs.release_version }}
|
||||
publishLatest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
|
||||
targets: apollo-router-hive-build
|
||||
build: false
|
||||
publishPrComment: true
|
||||
secrets: inherit
|
||||
|
||||
# Test the Docker image, if it was published
|
||||
test-image:
|
||||
name: test apollo-router docker image
|
||||
needs:
|
||||
- dockerize
|
||||
- find-changes
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
HIVE_TOKEN: ${{ secrets.HIVE_TOKEN }}
|
||||
steps:
|
||||
- name: Run Docker image
|
||||
run: |
|
||||
# Create router.yaml
|
||||
cat << EOF > router.yaml
|
||||
supergraph:
|
||||
listen: 0.0.0.0:4000
|
||||
health_check:
|
||||
listen: 0.0.0.0:8088
|
||||
enabled: true
|
||||
path: /health
|
||||
plugins:
|
||||
hive.usage:
|
||||
enabled: false
|
||||
EOF
|
||||
|
||||
# Download supergraph
|
||||
curl -sSL https://supergraph.demo.starstuff.dev/ > ./supergraph.graphql
|
||||
|
||||
# Run Docker image
|
||||
docker run -p 4000:4000 -p 8088:8088 --name apollo_router_test -d \
|
||||
--env HIVE_TOKEN="fake" \
|
||||
--mount "type=bind,source=/$(pwd)/router.yaml,target=/dist/config/router.yaml" \
|
||||
--mount "type=bind,source=/$(pwd)/supergraph.graphql,target=/dist/config/supergraph.graphql" \
|
||||
ghcr.io/graphql-hive/apollo-router:${{ needs.find-changes.outputs.release_version }} \
|
||||
--log debug \
|
||||
--supergraph /dist/config/supergraph.graphql \
|
||||
--config /dist/config/router.yaml
|
||||
|
||||
# Wait for the container to be ready
|
||||
echo "Waiting for the container to be ready..."
|
||||
sleep 20
|
||||
HTTP_RESPONSE=$(curl --retry 5 --retry-delay 5 --max-time 30 --write-out "%{http_code}" --silent --output /dev/null "http://127.0.0.1:8088/health")
|
||||
|
||||
# Check if the HTTP response code is 200 (OK)
|
||||
if [ $HTTP_RESPONSE -eq 200 ]; then
|
||||
echo "Health check successful."
|
||||
docker stop apollo_router_test
|
||||
docker rm apollo_router_test
|
||||
exit 0
|
||||
else
|
||||
echo "Health check failed with HTTP status code $HTTP_RESPONSE."
|
||||
docker stop apollo_router_test
|
||||
docker rm apollo_router_test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build and publish Rust crates and binaries
|
||||
binary:
|
||||
uses: ./.github/workflows/publish-rust.yaml
|
||||
secrets: inherit
|
||||
needs:
|
||||
- find-changes
|
||||
if: ${{ needs.find-changes.outputs.should_release == 'true' }}
|
||||
with:
|
||||
publish: true
|
||||
latest: ${{ needs.find-changes.outputs.release_latest == 'true' }}
|
||||
version: ${{ needs.find-changes.outputs.release_version }}
|
||||
57
.github/workflows/apollo-router-updater.yaml
vendored
57
.github/workflows/apollo-router-updater.yaml
vendored
|
|
@ -1,57 +0,0 @@
|
|||
name: Apollo Router Updater
|
||||
on:
|
||||
schedule:
|
||||
# Every 2 hours
|
||||
- cron: '0 */2 * * *'
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
|
||||
- name: setup environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
codegen: false
|
||||
actor: apollo-router-updater
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
with:
|
||||
toolchain: '1.91.1'
|
||||
default: true
|
||||
override: true
|
||||
|
||||
- name: Check for updates
|
||||
id: check
|
||||
run: |
|
||||
pnpm tsx ./scripts/apollo-router-action.ts
|
||||
|
||||
- name: Run updates
|
||||
if: steps.check.outputs.update == 'true'
|
||||
run: cargo update -p apollo-router --precise ${{ steps.check.outputs.version }}
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check.outputs.update == 'true'
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
commit-message: Update apollo-router to version ${{ steps.check.outputs.version }}
|
||||
branch: apollo-router-update-${{ steps.check.outputs.version }}
|
||||
delete-branch: true
|
||||
title: ${{ steps.check.outputs.title }}
|
||||
body: |
|
||||
Automatic update of apollo-router to version ${{ steps.check.outputs.version }}.
|
||||
assignees: kamilkisiela,dotansimha
|
||||
reviewers: kamilkisiela,dotansimha
|
||||
7
.github/workflows/build-and-dockerize.yaml
vendored
7
.github/workflows/build-and-dockerize.yaml
vendored
|
|
@ -45,6 +45,9 @@ jobs:
|
|||
suffix: '-arm64'
|
||||
runs-on: ${{ matrix.builder }}
|
||||
name: dockerize (${{ matrix.platform }})
|
||||
env:
|
||||
SENTRY_ORG: the-guild-z4
|
||||
SENTRY_PROJECT: graphql-hive
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
|
@ -66,6 +69,8 @@ jobs:
|
|||
run: pnpm build
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=14336' # GitHub Actions gives us 16GB, it's ok to use 80%
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
RELEASE: ${{ inputs.imageTag }}
|
||||
|
||||
- name: test ESM & CJS exports integrity
|
||||
if: ${{ inputs.build }}
|
||||
|
|
@ -191,8 +196,6 @@ jobs:
|
|||
if: ${{ inputs.publishSourceMaps }}
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_ORG: the-guild-z4
|
||||
SENTRY_PROJECT: graphql-hive
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE: ${{ inputs.imageTag }}
|
||||
run: pnpm upload-sourcemaps
|
||||
|
|
|
|||
2
.github/workflows/pr.yaml
vendored
2
.github/workflows/pr.yaml
vendored
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
restoreDeletedChangesets: true
|
||||
buildScript: build:libraries
|
||||
packageManager: pnpm
|
||||
nodeVersion: '24.13'
|
||||
nodeVersion: '24.14'
|
||||
secrets:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
npmToken: ${{ secrets.NPM_TOKEN }}
|
||||
|
|
|
|||
169
.github/workflows/publish-rust.yaml
vendored
169
.github/workflows/publish-rust.yaml
vendored
|
|
@ -1,169 +0,0 @@
|
|||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
publish:
|
||||
default: false
|
||||
type: boolean
|
||||
required: true
|
||||
latest:
|
||||
default: false
|
||||
type: boolean
|
||||
required: true
|
||||
version:
|
||||
default: ${{ github.sha }}
|
||||
type: string
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
rust_changed: ${{ steps.rust_changed.outputs.rust_changed }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- name: Look for changes
|
||||
id: rust_changed
|
||||
run: |
|
||||
lines=$( git diff HEAD~ HEAD --name-only -- 'packages/libraries/router' 'packages/libraries/sdk-rs' 'Cargo.toml' 'configs/cargo/Cargo.lock' | wc -l )
|
||||
if [ $lines -gt 0 ]; then
|
||||
echo 'rust_changed=true' >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
test-rust:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.rust_changed == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: setup environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
actor: test-rust
|
||||
codegen: false
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
with:
|
||||
toolchain: '1.91.1'
|
||||
default: true
|
||||
override: true
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test
|
||||
|
||||
publish-rust:
|
||||
needs: [detect-changes, test-rust]
|
||||
if: needs.detect-changes.outputs.rust_changed == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: setup environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
actor: publish-rust
|
||||
codegen: false
|
||||
|
||||
- name: Prepare MacOS
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
run: |
|
||||
echo "RUST_TARGET=x86_64-apple-darwin" >> $GITHUB_ENV
|
||||
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
|
||||
echo "RUST_TARGET_OS=macos" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare Linux
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: |
|
||||
echo "RUST_TARGET=x86_64-unknown-linux-gnu" >> $GITHUB_ENV
|
||||
echo "RUST_TARGET_FILE=router" >> $GITHUB_ENV
|
||||
echo "RUST_TARGET_OS=linux" >> $GITHUB_ENV
|
||||
|
||||
- name: Prepare Windows
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: |
|
||||
echo "RUST_TARGET=x86_64-pc-windows-msvc" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "RUST_TARGET_FILE=router.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "RUST_TARGET_OS=win" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
npm run cargo:fix
|
||||
|
||||
- name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
with:
|
||||
toolchain: '1.91.1'
|
||||
target: ${{ env.RUST_TARGET }}
|
||||
default: true
|
||||
override: true
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||
|
||||
- name: Build
|
||||
uses: actions-rs/cargo@844f36862e911db73fe0815f00a4a2602c279505 # v1
|
||||
with:
|
||||
command: build
|
||||
args: --release
|
||||
|
||||
- name: Strip binary from debug symbols
|
||||
if: ${{ matrix.os == 'ubuntu-latest' }}
|
||||
run: strip target/release/${{ env.RUST_TARGET_FILE }}
|
||||
|
||||
- name: Compress
|
||||
run: |
|
||||
./target/release/compress ./target/release/${{ env.RUST_TARGET_FILE }} ./router.tar.gz
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4
|
||||
with:
|
||||
name: router-${{ env.RUST_TARGET_OS }}
|
||||
path: router.tar.gz
|
||||
|
||||
- name: Upload to R2 (${{ inputs.version }})
|
||||
if: ${{ inputs.publish }}
|
||||
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
|
||||
with:
|
||||
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
|
||||
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
bucket: apollo-router
|
||||
file: router.tar.gz
|
||||
destination: ${{ inputs.version }}/${{ env.RUST_TARGET_OS }}/router.tar.gz
|
||||
|
||||
- name: Upload to R2 (latest)
|
||||
if: ${{ inputs.publish && inputs.latest }}
|
||||
uses: randomairborne/r2-release@9cbc35a2039ee2ef453a6988cd2a85bb2d7ba8af # v1.0.2
|
||||
with:
|
||||
endpoint: https://6d5bc18cd8d13babe7ed321adba3d8ae.r2.cloudflarestorage.com
|
||||
accesskeyid: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
secretaccesskey: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
bucket: apollo-router
|
||||
file: router.tar.gz
|
||||
destination: latest/${{ env.RUST_TARGET_OS }}/router.tar.gz
|
||||
2
.github/workflows/release-alpha.yaml
vendored
2
.github/workflows/release-alpha.yaml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
npmTag: alpha
|
||||
buildScript: build:libraries
|
||||
packageManager: pnpm
|
||||
nodeVersion: '24.13'
|
||||
nodeVersion: '24.14'
|
||||
secrets:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
npmToken: ${{ secrets.NPM_TOKEN }}
|
||||
|
|
|
|||
8
.github/workflows/release-stable.yaml
vendored
8
.github/workflows/release-stable.yaml
vendored
|
|
@ -118,11 +118,3 @@ jobs:
|
|||
env:
|
||||
VERSION: ${{ steps.cli.outputs.version }}
|
||||
run: pnpm oclif promote --no-xz --sha ${GITHUB_SHA:0:7} --version $VERSION
|
||||
|
||||
- name: release to Crates.io
|
||||
if:
|
||||
steps.changesets.outputs.published && contains(steps.changesets.outputs.publishedPackages,
|
||||
'"hive-apollo-router-plugin"')
|
||||
run: |
|
||||
cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
cargo publish --allow-dirty --no-verify
|
||||
|
|
|
|||
65
.github/workflows/tests-integration.yaml
vendored
65
.github/workflows/tests-integration.yaml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
# Divide integration tests into 3 shards, to run them in parallel.
|
||||
shardIndex: [1, 2, 3, 'apollo-router']
|
||||
shardIndex: [1, 2, 3]
|
||||
|
||||
env:
|
||||
DOCKER_REGISTRY: ${{ inputs.registry }}/${{ inputs.imageName }}/
|
||||
|
|
@ -78,68 +78,7 @@ jobs:
|
|||
run: |
|
||||
docker compose -f docker/docker-compose.community.yml -f ./integration-tests/docker-compose.integration.yaml --env-file ./integration-tests/.env ps
|
||||
|
||||
## ---- START ---- Apollo Router specific steps
|
||||
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: Install Protoc
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: Install Rust
|
||||
uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1
|
||||
with:
|
||||
toolchain: '1.91.1'
|
||||
default: true
|
||||
override: true
|
||||
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: Cache Rust
|
||||
uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
|
||||
|
||||
# ---- START ---- Disk space cleanup before apollo router tests
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: Before cleanup disk space
|
||||
run: df -h
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: Cleanup disk space
|
||||
run: |
|
||||
sudo rm -rf \
|
||||
/usr/lib/jvm \
|
||||
/usr/share/swift \
|
||||
/usr/local/julia* \
|
||||
/usr/local/share/chromium \
|
||||
/opt/az \
|
||||
/usr/local/share/powershell \
|
||||
/opt/microsoft /opt/google \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/local/lib/android \
|
||||
/opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force
|
||||
sudo docker builder prune -a
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: After cleanup disk space
|
||||
run: df -h
|
||||
# ---- END ---- Disk space cleanup before apollo router tests
|
||||
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: build apollo router
|
||||
run: |
|
||||
cargo build
|
||||
|
||||
- if: matrix.shardIndex == 'apollo-router'
|
||||
name: run apollo router integration tests
|
||||
timeout-minutes: 30
|
||||
run: |
|
||||
pnpm test:integration:apollo-router
|
||||
|
||||
## ---- END ---- Apollo Router specific steps
|
||||
|
||||
- if: matrix.shardIndex != 'apollo-router'
|
||||
name: run integration tests
|
||||
- name: run integration tests
|
||||
timeout-minutes: 30
|
||||
run: |
|
||||
pnpm test:integration --shard=${{ matrix.shardIndex }}/3
|
||||
|
|
|
|||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -110,9 +110,6 @@ integration-tests/testkit/gql/
|
|||
|
||||
npm-shrinkwrap.json
|
||||
|
||||
# Rust
|
||||
/target
|
||||
|
||||
# bob
|
||||
.bob/
|
||||
|
||||
|
|
@ -127,6 +124,7 @@ packages/web/app/next.config.mjs
|
|||
packages/web/app/environment-*.mjs
|
||||
packages/web/app/src/gql/*.ts
|
||||
packages/web/app/src/gql/*.json
|
||||
|
||||
# Changelog
|
||||
packages/web/app/src/components/ui/changelog/generated-changelog.ts
|
||||
|
||||
|
|
@ -142,20 +140,7 @@ resolvers.generated.ts
|
|||
docker/docker-compose.override.yml
|
||||
|
||||
test-results/
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
|
||||
target
|
||||
Cargo.lock
|
||||
Cargo.lock
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
24.13
|
||||
24.14.1
|
||||
|
|
|
|||
7675
Cargo.lock
generated
7675
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +0,0 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["packages/libraries/router", "scripts/compress"]
|
||||
7313
configs/cargo/Cargo.lock
generated
7313
configs/cargo/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,115 @@
|
|||
# hive
|
||||
|
||||
## 11.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-72c6-fx6q-fr5w](https://github.com/advisories/GHSA-72c6-fx6q-fr5w).
|
||||
|
||||
- [#7988](https://github.com/graphql-hive/console/pull/7988)
|
||||
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-247c-9743-5963](https://github.com/advisories/GHSA-247c-9743-5963).
|
||||
|
||||
- [#7961](https://github.com/graphql-hive/console/pull/7961)
|
||||
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
|
||||
[`nodemailer`](https://github.com/nodemailer/nodemailer) to address vulnerability
|
||||
[GHSA-vvjj-xcjg-gr5g](https://github.com/advisories/GHSA-vvjj-xcjg-gr5g).
|
||||
|
||||
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-v9ww-2j6r-98q6](https://github.com/advisories/GHSA-v9ww-2j6r-98q6).
|
||||
|
||||
- [#7976](https://github.com/graphql-hive/console/pull/7976)
|
||||
[`ed9ab34`](https://github.com/graphql-hive/console/commit/ed9ab34c705be4b7946dfcbede91926f00f1ed4a)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - address vulnerability
|
||||
[GHSA-r4q5-vmmm-2653](https://github.com/advisories/GHSA-r4q5-vmmm-2653)
|
||||
|
||||
- [#7967](https://github.com/graphql-hive/console/pull/7967)
|
||||
[`9708f71`](https://github.com/graphql-hive/console/commit/9708f71aa3e6dcd613f3877a0777c1e72710b200)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Fix schema contract composition applying
|
||||
`@inaccessible` on the federation types `ContextArgument` and `FieldValue` on the supergraph SDL.
|
||||
|
||||
This mitigates the following error in apollo-router upon processing the supergraph:
|
||||
|
||||
```
|
||||
could not create router: Api error(s): The supergraph schema failed to produce a valid API schema: The following errors occurred:
|
||||
- Core feature type `join__ContextArgument` cannot use @inaccessible.
|
||||
- Core feature type `join__FieldValue` cannot use @inaccessible.
|
||||
```
|
||||
|
||||
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-xq3m-2v4x-88gg](https://github.com/advisories/GHSA-xq3m-2v4x-88gg).
|
||||
|
||||
- [#7978](https://github.com/graphql-hive/console/pull/7978)
|
||||
[`9c6989c`](https://github.com/graphql-hive/console/commit/9c6989cd929df3b071cba2c5652cc18988127897)
|
||||
Thanks [@jdolle](https://github.com/jdolle)! - Add schema linting support for type extensions
|
||||
|
||||
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[CVE-2026-6414](https://github.com/advisories/GHSA-x428-ghpx-8j92).
|
||||
|
||||
- [#7988](https://github.com/graphql-hive/console/pull/7988)
|
||||
[`d7e7025`](https://github.com/graphql-hive/console/commit/d7e7025624ba66459515778c0724a58397a5f1b4)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-39q2-94rc-95cp](https://github.com/advisories/GHSA-39q2-94rc-95cp).
|
||||
|
||||
- [#7980](https://github.com/graphql-hive/console/pull/7980)
|
||||
[`c46b2f2`](https://github.com/graphql-hive/console/commit/c46b2f221936ca60e49bce3a2fea25bb40378266)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[GHSA-fvcv-3m26-pcqx](https://github.com/advisories/GHSA-fvcv-3m26-pcqx).
|
||||
|
||||
- [#7993](https://github.com/graphql-hive/console/pull/7993)
|
||||
[`730771f`](https://github.com/graphql-hive/console/commit/730771fb503fd91974c1494944cd5426cf74a552)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Address vulnerability
|
||||
[CVE-2026-6410](https://github.com/advisories/GHSA-pr96-94w5-mx2h).
|
||||
|
||||
- [#7961](https://github.com/graphql-hive/console/pull/7961)
|
||||
[`40fd27d`](https://github.com/graphql-hive/console/commit/40fd27d9c060df5417c18c750b02af65451e5323)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Update
|
||||
[`opentelemetry-go`](https://github.com/open-telemetry/opentelemetry-go) to address vulnerability
|
||||
[CVE-2026-39883](https://github.com/advisories/GHSA-hfvc-g4fc-pqhx).
|
||||
|
||||
## 11.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7940](https://github.com/graphql-hive/console/pull/7940)
|
||||
[`742f50c`](https://github.com/graphql-hive/console/commit/742f50c52e846ab63843635c6f408016a30f6288)
|
||||
Thanks [@jdolle](https://github.com/jdolle)! - Fix lint policy block title color; add policy link
|
||||
to lines and rule id tooltip
|
||||
|
||||
- [#7936](https://github.com/graphql-hive/console/pull/7936)
|
||||
[`96bd390`](https://github.com/graphql-hive/console/commit/96bd390c7ffa75baf7db0c0bb3a3117c6ef6f631)
|
||||
Thanks [@jdolle](https://github.com/jdolle)! - Do not cache edge types in graphql eslint. This
|
||||
fixes an issue where edge types were cached between runs and only the cached edge types would be
|
||||
referenced for subsequent runs
|
||||
|
||||
## 11.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7909](https://github.com/graphql-hive/console/pull/7909)
|
||||
[`484054b`](https://github.com/graphql-hive/console/commit/484054be2773431a419be2c5ab16742f81d6b895)
|
||||
Thanks [@jdolle](https://github.com/jdolle)! - Support project level token expiration
|
||||
|
||||
- [#7908](https://github.com/graphql-hive/console/pull/7908)
|
||||
[`70b3e19`](https://github.com/graphql-hive/console/commit/70b3e19fe2e6e064f5693f8acfbfcae8182faac4)
|
||||
Thanks [@n1ru4l](https://github.com/n1ru4l)! - Fix web app pagination for access tokens.
|
||||
|
||||
- [#7893](https://github.com/graphql-hive/console/pull/7893)
|
||||
[`e5711a5`](https://github.com/graphql-hive/console/commit/e5711a52a87b9e66147de08378f8c3d17336d175)
|
||||
Thanks [@jdolle](https://github.com/jdolle)! - Add expiration to all tokens; fix token ui spacing
|
||||
issues
|
||||
|
||||
## 11.0.0
|
||||
|
||||
### Major Changes
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ async function generateVectorDevTypes() {
|
|||
}
|
||||
|
||||
async function generateContourTypes() {
|
||||
const helmValuesFileUrl = `https://raw.githubusercontent.com/bitnami/charts/contour/${CONTOUR_CHART.version}/bitnami/contour/values.yaml`;
|
||||
const helmValuesFileUrl = `https://raw.githubusercontent.com/projectcontour/helm-charts/refs/tags/contour-${CONTOUR_CHART.version}/charts/contour/values.yaml`;
|
||||
const valuesFile = await fetch(helmValuesFileUrl).then(r => r.text());
|
||||
|
||||
const valuesTempFile = fileSync();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hive",
|
||||
"version": "11.0.0",
|
||||
"version": "11.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"generate": "tsx generate.ts",
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"@graphql-hive/gateway": "^2.1.19",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/node": "24.10.9",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/tmp": "0.2.6",
|
||||
"json-schema-to-typescript": "15.0.3",
|
||||
"tmp": "0.2.5",
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ export function prepareEnvironment(input: {
|
|||
},
|
||||
envoy: {
|
||||
replicas: isProduction || isStaging ? 3 : 1,
|
||||
cpuLimit: isProduction ? '1500m' : '120m',
|
||||
memoryLimit: isProduction ? '2Gi' : '200Mi',
|
||||
cpuLimit: isProduction ? '1500m' : isStaging ? '300m' : '120m',
|
||||
memoryLimit: isProduction || isStaging ? '2Gi' : '200Mi',
|
||||
timeouts: {
|
||||
idleTimeout: 905,
|
||||
},
|
||||
|
|
@ -60,29 +60,34 @@ export function prepareEnvironment(input: {
|
|||
memoryLimit: isProduction || isStaging ? '3584Mi' : '1Gi',
|
||||
},
|
||||
usageService: {
|
||||
replicas: isProduction || isStaging ? 3 : 1,
|
||||
cpuLimit: isProduction ? '1000m' : '100m',
|
||||
replicas: isProduction || isStaging ? 6 : 1,
|
||||
cpuMin: isProduction || isStaging ? '200m' : '100m',
|
||||
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||
maxReplicas: isProduction || isStaging ? 6 : 1,
|
||||
cpuAverageToScale: 60,
|
||||
},
|
||||
usageIngestorService: {
|
||||
replicas: isProduction || isStaging ? 6 : 1,
|
||||
cpuLimit: isProduction ? '1000m' : '100m',
|
||||
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||
cpuMin: isProduction || isStaging ? '300m' : '100m',
|
||||
maxReplicas: isProduction || isStaging ? /* numberOfPartitions */ 16 : 2,
|
||||
cpuAverageToScale: 60,
|
||||
},
|
||||
redis: {
|
||||
memoryLimit: isProduction ? '4Gi' : '100Mi',
|
||||
cpuLimit: isProduction ? '1000m' : '50m',
|
||||
memoryLimit: isProduction || isStaging ? '4Gi' : '100Mi',
|
||||
cpuMax: isProduction || isStaging ? '1000m' : '100m',
|
||||
cpuMin: isProduction || isStaging ? '100m' : '50m',
|
||||
},
|
||||
internalObservability: {
|
||||
cpuLimit: isProduction ? '512m' : '150m',
|
||||
memoryLimit: isProduction ? '1000Mi' : '300Mi',
|
||||
},
|
||||
tracingCollector: {
|
||||
cpuLimit: isProduction || isStaging ? '1000m' : '100m',
|
||||
cpuMax: isProduction || isStaging ? '1000m' : '300m',
|
||||
cpuMin: '100m',
|
||||
memoryLimit: isProduction || isStaging ? '1000Mi' : '512Mi',
|
||||
maxReplicas: isProduction || isStaging ? 3 : 1,
|
||||
replicas: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -36,17 +36,20 @@ export function deployOTELCollector(args: {
|
|||
livenessProbe: '/',
|
||||
startupProbe: '/',
|
||||
exposesMetrics: true,
|
||||
replicas: args.environment.podsConfig.tracingCollector.maxReplicas,
|
||||
replicas: args.environment.podsConfig.tracingCollector.replicas,
|
||||
pdb: true,
|
||||
availabilityOnEveryNode: true,
|
||||
port: 4318,
|
||||
memoryLimit: args.environment.podsConfig.tracingCollector.memoryLimit,
|
||||
memory: {
|
||||
limit: args.environment.podsConfig.tracingCollector.memoryLimit,
|
||||
},
|
||||
cpu: {
|
||||
limit: args.environment.podsConfig.tracingCollector.cpuMax,
|
||||
requests: args.environment.podsConfig.tracingCollector.cpuMin,
|
||||
},
|
||||
autoScaling: {
|
||||
maxReplicas: args.environment.podsConfig.tracingCollector.maxReplicas,
|
||||
cpu: {
|
||||
limit: args.environment.podsConfig.tracingCollector.cpuLimit,
|
||||
cpuAverageToScale: 80,
|
||||
},
|
||||
cpuAverageToScale: 80,
|
||||
},
|
||||
},
|
||||
[args.clickhouse.deployment, args.clickhouse.service, args.dbMigrations],
|
||||
|
|
|
|||
|
|
@ -96,16 +96,13 @@ export function deployProxy({
|
|||
service: graphql.service,
|
||||
requestTimeout: '60s',
|
||||
retriable: true,
|
||||
rateLimit: {
|
||||
maxRequests: 10,
|
||||
unit: 'minute',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'usage',
|
||||
path: '/usage',
|
||||
service: usage.service,
|
||||
retriable: true,
|
||||
loadBalancerPolicy: 'WeightedLeastRequest',
|
||||
},
|
||||
])
|
||||
.registerService({ record: environment.apiDns }, [
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ export function deployRedis(input: { environment: Environment }) {
|
|||
}).deploy({
|
||||
limits: {
|
||||
memory: input.environment.podsConfig.redis.memoryLimit,
|
||||
cpu: input.environment.podsConfig.redis.cpuLimit,
|
||||
cpu: input.environment.podsConfig.redis.cpuMax,
|
||||
},
|
||||
requests: {
|
||||
cpu: input.environment.podsConfig.redis.cpuMin,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ export function deploySchema({
|
|||
startupProbe: '/_health',
|
||||
exposesMetrics: true,
|
||||
replicas: environment.podsConfig.general.replicas,
|
||||
memoryLimit: environment.podsConfig.schemaService.memoryLimit,
|
||||
memory: {
|
||||
limit: environment.podsConfig.schemaService.memoryLimit,
|
||||
},
|
||||
pdb: true,
|
||||
},
|
||||
[redis.deployment, redis.service],
|
||||
|
|
|
|||
|
|
@ -61,11 +61,12 @@ export function deployUsageIngestor({
|
|||
exposesMetrics: true,
|
||||
port: 4000,
|
||||
pdb: true,
|
||||
cpu: {
|
||||
limit: environment.podsConfig.usageIngestorService.cpuMax,
|
||||
requests: environment.podsConfig.usageIngestorService.cpuMin,
|
||||
},
|
||||
autoScaling: {
|
||||
cpu: {
|
||||
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
|
||||
limit: environment.podsConfig.usageIngestorService.cpuLimit,
|
||||
},
|
||||
cpuAverageToScale: environment.podsConfig.usageIngestorService.cpuAverageToScale,
|
||||
maxReplicas: environment.podsConfig.usageIngestorService.maxReplicas,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,11 +81,12 @@ export function deployUsage({
|
|||
exposesMetrics: true,
|
||||
port: 4000,
|
||||
pdb: true,
|
||||
cpu: {
|
||||
limit: environment.podsConfig.usageService.cpuMax,
|
||||
requests: environment.podsConfig.usageService.cpuMin,
|
||||
},
|
||||
autoScaling: {
|
||||
cpu: {
|
||||
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
|
||||
limit: environment.podsConfig.usageService.cpuLimit,
|
||||
},
|
||||
cpuAverageToScale: environment.podsConfig.usageService.cpuAverageToScale,
|
||||
maxReplicas: environment.podsConfig.usageService.maxReplicas,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -263,168 +263,6 @@ export interface ContourValues {
|
|||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
defaultBackend?: {
|
||||
affinity?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
args?: unknown[];
|
||||
command?: unknown[];
|
||||
containerPorts?: {
|
||||
http?: number;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
containerSecurityContext?: {
|
||||
allowPrivilegeEscalation?: boolean;
|
||||
capabilities?: {
|
||||
drop?: string[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
enabled?: boolean;
|
||||
privileged?: boolean;
|
||||
readOnlyRootFilesystem?: boolean;
|
||||
runAsGroup?: number;
|
||||
runAsNonRoot?: boolean;
|
||||
runAsUser?: number;
|
||||
seLinuxOptions?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
seccompProfile?: {
|
||||
type?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
customLivenessProbe?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
customReadinessProbe?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
customStartupProbe?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
enabled?: boolean;
|
||||
extraArgs?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
extraEnvVars?: unknown[];
|
||||
extraEnvVarsCM?: string;
|
||||
extraEnvVarsSecret?: string;
|
||||
extraVolumeMounts?: unknown[];
|
||||
extraVolumes?: unknown[];
|
||||
hostAliases?: unknown[];
|
||||
image?: {
|
||||
digest?: string;
|
||||
pullPolicy?: string;
|
||||
pullSecrets?: unknown[];
|
||||
registry?: string;
|
||||
repository?: string;
|
||||
tag?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
initContainers?: unknown[];
|
||||
lifecycleHooks?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
livenessProbe?: {
|
||||
enabled?: boolean;
|
||||
failureThreshold?: number;
|
||||
initialDelaySeconds?: number;
|
||||
periodSeconds?: number;
|
||||
successThreshold?: number;
|
||||
timeoutSeconds?: number;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
networkPolicy?: {
|
||||
allowExternal?: boolean;
|
||||
allowExternalEgress?: boolean;
|
||||
enabled?: boolean;
|
||||
extraEgress?: unknown[];
|
||||
extraIngress?: unknown[];
|
||||
ingressNSMatchLabels?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
ingressNSPodMatchLabels?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
nodeAffinityPreset?: {
|
||||
key?: string;
|
||||
type?: string;
|
||||
values?: unknown[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
nodeSelector?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
pdb?: {
|
||||
create?: boolean;
|
||||
maxUnavailable?: string;
|
||||
minAvailable?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
podAffinityPreset?: string;
|
||||
podAnnotations?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
podAntiAffinityPreset?: string;
|
||||
podLabels?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
podSecurityContext?: {
|
||||
enabled?: boolean;
|
||||
fsGroup?: number;
|
||||
fsGroupChangePolicy?: string;
|
||||
supplementalGroups?: unknown[];
|
||||
sysctls?: unknown[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
priorityClassName?: string;
|
||||
readinessProbe?: {
|
||||
enabled?: boolean;
|
||||
failureThreshold?: number;
|
||||
initialDelaySeconds?: number;
|
||||
periodSeconds?: number;
|
||||
successThreshold?: number;
|
||||
timeoutSeconds?: number;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
replicaCount?: number;
|
||||
resources?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
resourcesPreset?: string;
|
||||
schedulerName?: string;
|
||||
service?: {
|
||||
annotations?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
ports?: {
|
||||
http?: number;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
type?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
sidecars?: unknown[];
|
||||
startupProbe?: {
|
||||
enabled?: boolean;
|
||||
failureThreshold?: number;
|
||||
initialDelaySeconds?: number;
|
||||
periodSeconds?: number;
|
||||
successThreshold?: number;
|
||||
timeoutSeconds?: number;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
terminationGracePeriodSeconds?: number;
|
||||
tolerations?: unknown[];
|
||||
topologySpreadConstraints?: unknown[];
|
||||
updateStrategy?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
diagnosticMode?: {
|
||||
args?: number[];
|
||||
command?: string[];
|
||||
|
|
@ -485,6 +323,37 @@ export interface ContourValues {
|
|||
customStartupProbe?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
defaultInitContainers?: {
|
||||
initConfig?: {
|
||||
containerSecurityContext?: {
|
||||
allowPrivilegeEscalation?: boolean;
|
||||
capabilities?: {
|
||||
drop?: string[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
enabled?: boolean;
|
||||
privileged?: boolean;
|
||||
readOnlyRootFilesystem?: boolean;
|
||||
runAsGroup?: number;
|
||||
runAsNonRoot?: boolean;
|
||||
runAsUser?: number;
|
||||
seLinuxOptions?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
seccompProfile?: {
|
||||
type?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
resources?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
resourcesPreset?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
dnsPolicy?: string;
|
||||
enabled?: boolean;
|
||||
extraArgs?: unknown[];
|
||||
|
|
@ -516,30 +385,6 @@ export interface ContourValues {
|
|||
tag?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
initConfig?: {
|
||||
containerSecurityContext?: {
|
||||
allowPrivilegeEscalation?: boolean;
|
||||
capabilities?: {
|
||||
drop?: string[];
|
||||
[k: string]: unknown;
|
||||
};
|
||||
enabled?: boolean;
|
||||
privileged?: boolean;
|
||||
readOnlyRootFilesystem?: boolean;
|
||||
runAsGroup?: number;
|
||||
runAsNonRoot?: boolean;
|
||||
runAsUser?: number;
|
||||
seLinuxOptions?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
seccompProfile?: {
|
||||
type?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
initContainers?: unknown[];
|
||||
kind?: string;
|
||||
lifecycleHooks?: {
|
||||
|
|
@ -796,34 +641,9 @@ export interface ContourValues {
|
|||
defaultStorageClass?: string;
|
||||
imagePullSecrets?: unknown[];
|
||||
imageRegistry?: string;
|
||||
security?: {
|
||||
allowInsecureImages?: boolean;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
storageClass?: string;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
ingress?: {
|
||||
annotations?: {
|
||||
[k: string]: unknown;
|
||||
};
|
||||
apiVersion?: string;
|
||||
certManager?: boolean;
|
||||
enabled?: boolean;
|
||||
extraHosts?: unknown[];
|
||||
extraPaths?: unknown[];
|
||||
extraRules?: unknown[];
|
||||
extraTls?: unknown[];
|
||||
hostname?: string;
|
||||
ingressClassName?: string;
|
||||
path?: string;
|
||||
pathType?: string;
|
||||
rulesOverride?: unknown[];
|
||||
secrets?: unknown[];
|
||||
selfSigned?: boolean;
|
||||
tls?: boolean;
|
||||
[k: string]: unknown;
|
||||
};
|
||||
kubeVersion?: string;
|
||||
metrics?: {
|
||||
prometheusRule?: {
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@ export class Observability {
|
|||
{
|
||||
role: 'pod',
|
||||
namespaces: {
|
||||
names: ['default'],
|
||||
names: ['default', 'contour'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -18,13 +18,16 @@ export class Redis {
|
|||
},
|
||||
) {}
|
||||
|
||||
deploy(input: { limits: { memory: string; cpu: string } }) {
|
||||
deploy(input: { limits: { memory: string; cpu: string }; requests: { cpu: string } }) {
|
||||
const redisService = getLocalComposeConfig().service('redis');
|
||||
const name = 'redis-store';
|
||||
const limits: k8s.types.input.core.v1.ResourceRequirements['limits'] = {
|
||||
memory: input.limits.memory,
|
||||
cpu: input.limits.cpu,
|
||||
};
|
||||
const requests: k8s.types.input.core.v1.ResourceRequirements['requests'] = {
|
||||
cpu: input.requests.cpu,
|
||||
};
|
||||
|
||||
const env: k8s.types.input.core.v1.EnvVar[] = normalizeEnv(this.options.env ?? {}).concat([
|
||||
{
|
||||
|
|
@ -100,6 +103,7 @@ export class Redis {
|
|||
ports: [{ containerPort: REDIS_PORT, protocol: 'TCP' }],
|
||||
resources: {
|
||||
limits,
|
||||
requests,
|
||||
},
|
||||
livenessProbe: {
|
||||
initialDelaySeconds: 3,
|
||||
|
|
@ -139,6 +143,10 @@ export class Redis {
|
|||
cpu: '200m',
|
||||
memory: '200Mi',
|
||||
},
|
||||
requests: {
|
||||
cpu: '100m',
|
||||
memory: '100Mi',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { ContourValues } from './contour.types';
|
|||
import { helmChart } from './helm';
|
||||
|
||||
// prettier-ignore
|
||||
export const CONTOUR_CHART = helmChart('https://raw.githubusercontent.com/bitnami/charts/refs/heads/index/bitnami/', 'contour', '20.0.3');
|
||||
export const CONTOUR_CHART = helmChart('https://projectcontour.github.io/helm-charts/', 'contour', '0.4.0');
|
||||
|
||||
export class Proxy {
|
||||
private lbService: Output<k8s.core.v1.Service> | null = null;
|
||||
|
|
@ -84,23 +84,13 @@ export class Proxy {
|
|||
requestTimeout?: `${number}s` | 'infinity';
|
||||
idleTimeout?: `${number}s`;
|
||||
retriable?: boolean;
|
||||
loadBalancerPolicy?:
|
||||
| 'WeightedLeastRequest'
|
||||
| 'RoundRobin'
|
||||
| 'Random'
|
||||
| 'RequestHash'
|
||||
| 'Cookie';
|
||||
customRewrite?: string;
|
||||
virtualHost?: Output<string>;
|
||||
httpsUpstream?: boolean;
|
||||
withWwwDomain?: boolean;
|
||||
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
|
||||
rateLimit?: {
|
||||
// Max amount of request allowed with the "unit" parameter.
|
||||
maxRequests: number;
|
||||
unit: 'second' | 'minute' | 'hour';
|
||||
// defining the number of requests above the baseline rate that are allowed in a short period of time.
|
||||
// This would allow occasional larger bursts of traffic not to be rate limited.
|
||||
burst?: number;
|
||||
// default 429
|
||||
responseStatusCode?: number;
|
||||
// headers to add to the response in case of a rate limit
|
||||
responseHeadersToAdd?: Record<string, string>;
|
||||
};
|
||||
}[],
|
||||
) {
|
||||
const cert = new k8s.apiextensions.CustomResource(`cert-${dns.record}`, {
|
||||
|
|
@ -153,32 +143,10 @@ export class Proxy {
|
|||
port: route.service.spec.ports[0].port,
|
||||
},
|
||||
],
|
||||
// https://projectcontour.io/docs/1.29/config/request-routing/#session-affinity
|
||||
// https://projectcontour.io/docs/1.33/config/request-routing/
|
||||
loadBalancerPolicy: {
|
||||
strategy: 'Cookie',
|
||||
strategy: route.loadBalancerPolicy ?? 'RoundRobin',
|
||||
},
|
||||
// https://projectcontour.io/docs/1.29/config/rate-limiting/#local-rate-limiting
|
||||
rateLimitPolicy: route.rateLimit
|
||||
? {
|
||||
local: {
|
||||
requests: route.rateLimit.maxRequests,
|
||||
unit: route.rateLimit.unit,
|
||||
responseHeadersToAdd: [
|
||||
{
|
||||
name: 'x-rate-limit-active',
|
||||
value: 'true',
|
||||
},
|
||||
...(route.rateLimit.responseHeadersToAdd
|
||||
? Object.entries(route.rateLimit.responseHeadersToAdd).map(
|
||||
([key, value]) => ({ name: key, value }),
|
||||
)
|
||||
: []),
|
||||
],
|
||||
responseStatusCode: route.rateLimit.responseStatusCode || 429,
|
||||
burst: route.rateLimit.burst,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
...(route.path === '/'
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -312,16 +280,7 @@ export class Proxy {
|
|||
}
|
||||
: {}),
|
||||
},
|
||||
// Needed because we override the `contour.image.repository` field.
|
||||
global: {
|
||||
security: {
|
||||
allowInsecureImages: true,
|
||||
},
|
||||
},
|
||||
contour: {
|
||||
image: {
|
||||
repository: 'bitnamilegacy/contour',
|
||||
},
|
||||
podAnnotations: {
|
||||
'prometheus.io/scrape': 'true',
|
||||
'prometheus.io/port': '8000',
|
||||
|
|
@ -331,14 +290,13 @@ export class Proxy {
|
|||
podLabels: {
|
||||
'vector.dev/exclude': 'true',
|
||||
},
|
||||
// Placeholder, see below
|
||||
resources: {
|
||||
limits: {},
|
||||
},
|
||||
},
|
||||
envoy: {
|
||||
image: {
|
||||
repository: 'bitnamilegacy/envoy',
|
||||
},
|
||||
// Placeholder, see below
|
||||
resources: {
|
||||
limits: {},
|
||||
},
|
||||
|
|
@ -380,7 +338,7 @@ export class Proxy {
|
|||
const proxyController = new k8s.helm.v3.Chart('contour-proxy', {
|
||||
...CONTOUR_CHART,
|
||||
namespace: ns.metadata.name,
|
||||
// https://github.com/bitnami/charts/tree/master/bitnami/contour
|
||||
// https://artifacthub.io/packages/helm/contour/contour
|
||||
values: chartValues,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@ export class ServiceDeployment {
|
|||
livenessProbe?: string | ProbeConfig;
|
||||
readinessProbe?: string | ProbeConfig;
|
||||
startupProbe?: string | ProbeConfig;
|
||||
memoryLimit?: string;
|
||||
memory?: {
|
||||
limit?: string;
|
||||
requests?: string;
|
||||
};
|
||||
cpu?: {
|
||||
limit?: string;
|
||||
requests?: string;
|
||||
};
|
||||
volumes?: k8s.types.input.core.v1.Volume[];
|
||||
volumeMounts?: k8s.types.input.core.v1.VolumeMount[];
|
||||
/**
|
||||
|
|
@ -58,10 +65,7 @@ export class ServiceDeployment {
|
|||
autoScaling?: {
|
||||
minReplicas?: number;
|
||||
maxReplicas: number;
|
||||
cpu: {
|
||||
limit: string;
|
||||
cpuAverageToScale: number;
|
||||
};
|
||||
cpuAverageToScale: number;
|
||||
};
|
||||
availabilityOnEveryNode?: boolean;
|
||||
command?: pulumi.Input<pulumi.Input<string>[]>;
|
||||
|
|
@ -205,14 +209,23 @@ export class ServiceDeployment {
|
|||
});
|
||||
}
|
||||
|
||||
const resourcesLimits: Record<string, string> = {};
|
||||
const resourcesLimits: { cpu?: string; memory?: string } = {};
|
||||
const resourcesRequests: { cpu?: string; memory?: string } = {};
|
||||
|
||||
if (this.options?.autoScaling?.cpu.limit) {
|
||||
resourcesLimits.cpu = this.options.autoScaling.cpu.limit;
|
||||
if (this.options?.cpu?.limit) {
|
||||
resourcesLimits.cpu = this.options?.cpu?.limit;
|
||||
}
|
||||
|
||||
if (this.options.memoryLimit) {
|
||||
resourcesLimits.memory = this.options.memoryLimit;
|
||||
if (this.options?.cpu?.requests) {
|
||||
resourcesRequests.cpu = this.options?.cpu?.requests;
|
||||
}
|
||||
|
||||
if (this.options?.memory?.limit) {
|
||||
resourcesLimits.memory = this.options?.memory?.limit;
|
||||
}
|
||||
|
||||
if (this.options?.memory?.requests) {
|
||||
resourcesRequests.memory = this.options?.memory?.requests;
|
||||
}
|
||||
|
||||
const pb = new PodBuilder({
|
||||
|
|
@ -248,6 +261,7 @@ export class ServiceDeployment {
|
|||
image: this.options.image,
|
||||
resources: {
|
||||
limits: resourcesLimits,
|
||||
requests: resourcesRequests,
|
||||
},
|
||||
args: this.options.args,
|
||||
ports: {
|
||||
|
|
@ -342,7 +356,7 @@ export class ServiceDeployment {
|
|||
name: 'cpu',
|
||||
target: {
|
||||
type: 'Utilization',
|
||||
averageUtilization: this.options.autoScaling.cpu.cpuAverageToScale,
|
||||
averageUtilization: this.options.autoScaling.cpuAverageToScale,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:24.13.0-slim
|
||||
FROM node:24.14.1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ require (
|
|||
go.opentelemetry.io/collector/extension v1.53.0
|
||||
go.opentelemetry.io/collector/extension/extensionauth v1.53.0
|
||||
go.opentelemetry.io/collector/extension/extensiontest v0.147.0
|
||||
go.opentelemetry.io/otel v1.40.0
|
||||
go.opentelemetry.io/otel/metric v1.40.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.uber.org/goleak v1.3.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/sync v0.19.0
|
||||
|
|
@ -41,13 +41,13 @@ require (
|
|||
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
|
||||
go.opentelemetry.io/collector/internal/componentalias v0.147.0 // indirect
|
||||
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
|
|
|
|||
|
|
@ -79,14 +79,24 @@ go.opentelemetry.io/collector/pdata v1.53.0 h1:DlYDbRwammEZaxDZHINx5v0n8SEOVNniP
|
|||
go.opentelemetry.io/collector/pdata v1.53.0/go.mod h1:LRSYGNjKXaUrZEwZv3Yl+8/zV2HmRGKXW62zB2bysms=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/slim/otlp v1.9.0 h1:fPVMv8tP3TrsqlkH1HWYUpbCY9cAIemx184VGkS6vlE=
|
||||
go.opentelemetry.io/proto/slim/otlp v1.9.0/go.mod h1:xXdeJJ90Gqyll+orzUkY4bOd2HECo5JofeoLpymVqdI=
|
||||
go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.2.0 h1:o13nadWDNkH/quoDomDUClnQBpdQQ2Qqv0lQBjIXjE8=
|
||||
|
|
@ -107,6 +117,8 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
|
|
|
|||
|
|
@ -337,25 +337,6 @@ target "app" {
|
|||
]
|
||||
}
|
||||
|
||||
target "apollo-router" {
|
||||
inherits = ["router-base", get_target()]
|
||||
contexts = {
|
||||
router_pkg = "${PWD}/packages/libraries/router"
|
||||
config = "${PWD}/configs/cargo"
|
||||
}
|
||||
args = {
|
||||
IMAGE_TITLE = "graphql-hive/apollo-router"
|
||||
PORT = "4000"
|
||||
IMAGE_DESCRIPTION = "Apollo Router for GraphQL Hive."
|
||||
}
|
||||
tags = [
|
||||
local_image_tag("apollo-router"),
|
||||
stable_image_tag("apollo-router"),
|
||||
image_tag("apollo-router", COMMIT_SHA),
|
||||
image_tag("apollo-router", BRANCH_NAME)
|
||||
]
|
||||
}
|
||||
|
||||
target "otel-collector" {
|
||||
inherits = ["otel-collector-base", get_target()]
|
||||
context = "${PWD}/docker/configs/otel-collector"
|
||||
|
|
@ -421,12 +402,6 @@ group "integration-tests" {
|
|||
]
|
||||
}
|
||||
|
||||
group "apollo-router-hive-build" {
|
||||
targets = [
|
||||
"apollo-router"
|
||||
]
|
||||
}
|
||||
|
||||
group "cli" {
|
||||
targets = [
|
||||
"cli"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:24.13.0-slim
|
||||
FROM node:24.14.1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM scratch AS router_pkg
|
||||
FROM scratch AS config
|
||||
|
||||
FROM rust:1.91.1-slim-bookworm AS build
|
||||
|
||||
# Required by Apollo Router
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install npm protobuf-compiler cmake
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
RUN update-ca-certificates
|
||||
RUN rustup component add rustfmt
|
||||
|
||||
WORKDIR /usr/src
|
||||
# Create blank projects
|
||||
RUN USER=root cargo new router
|
||||
|
||||
# Copy Cargo files
|
||||
COPY --from=router_pkg Cargo.toml /usr/src/router/
|
||||
COPY --from=config Cargo.lock /usr/src/router/
|
||||
|
||||
WORKDIR /usr/src/router
|
||||
# Get the dependencies cached, so we can use dummy input files so Cargo wont fail
|
||||
RUN echo 'fn main() { println!(""); }' > ./src/main.rs
|
||||
RUN echo 'fn main() { println!(""); }' > ./src/lib.rs
|
||||
RUN cargo build --release
|
||||
|
||||
# Copy in the actual source code
|
||||
COPY --from=router_pkg src ./src
|
||||
RUN touch ./src/main.rs
|
||||
RUN touch ./src/lib.rs
|
||||
|
||||
# Real build this time
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install ca-certificates
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
|
||||
LABEL org.opencontainers.image.title=$IMAGE_TITLE
|
||||
LABEL org.opencontainers.image.version=$RELEASE
|
||||
LABEL org.opencontainers.image.description=$IMAGE_DESCRIPTION
|
||||
LABEL org.opencontainers.image.authors="The Guild"
|
||||
LABEL org.opencontainers.image.vendor="Kamil Kisiela"
|
||||
LABEL org.opencontainers.image.url="https://github.com/graphql-hive/console"
|
||||
LABEL org.opencontainers.image.source="https://github.com/graphql-hive/console"
|
||||
|
||||
RUN mkdir -p /dist/config
|
||||
RUN mkdir /dist/schema
|
||||
|
||||
# Copy in the required files from our build image
|
||||
COPY --from=build --chown=root:root /usr/src/router/target/release/router /dist
|
||||
COPY --from=router_pkg router.yaml /dist/config/router.yaml
|
||||
|
||||
WORKDIR /dist
|
||||
|
||||
ENV APOLLO_ROUTER_CONFIG_PATH="/dist/config/router.yaml"
|
||||
|
||||
ENTRYPOINT ["./router"]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:24.13.0-slim
|
||||
FROM node:24.14.1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y wget ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ networks:
|
|||
|
||||
services:
|
||||
local_cdn:
|
||||
image: node:24.13.0-alpine3.23
|
||||
image: node:24.14.1-alpine3.23
|
||||
working_dir: /app
|
||||
command: ['node', 'index.js']
|
||||
networks:
|
||||
|
|
@ -38,7 +38,7 @@ services:
|
|||
S3_BUCKET_NAME: artifacts
|
||||
|
||||
local_broker:
|
||||
image: node:24.13.0-alpine3.23
|
||||
image: node:24.14.1-alpine3.23
|
||||
working_dir: /app
|
||||
command: ['node', 'broker.js']
|
||||
networks:
|
||||
|
|
@ -90,7 +90,7 @@ services:
|
|||
SECRET: '${EXTERNAL_COMPOSITION_SECRET}'
|
||||
|
||||
external_composition:
|
||||
image: node:24.13.0-alpine3.23
|
||||
image: node:24.14.1-alpine3.23
|
||||
working_dir: /app
|
||||
command: ['node', 'example.mjs']
|
||||
networks:
|
||||
|
|
@ -290,10 +290,10 @@ services:
|
|||
|
||||
# It's not part of integration tests
|
||||
app:
|
||||
image: node:24.13.0-alpine3.23
|
||||
image: node:24.14.1-alpine3.23
|
||||
command: ['npx', 'http-server']
|
||||
|
||||
# Redpand is used for integration tests, instead of Kafka. Zookeeper is no longer needed
|
||||
zookeeper:
|
||||
image: node:24.13.0-alpine3.23
|
||||
image: node:24.14.1-alpine3.23
|
||||
command: ['npx', 'http-server']
|
||||
|
|
|
|||
|
|
@ -7,12 +7,11 @@
|
|||
"prepare:env": "cd ../ && pnpm build:libraries && pnpm build:services",
|
||||
"start": "./local.sh",
|
||||
"test:integration": "vitest",
|
||||
"test:integration:apollo-router": "TEST_APOLLO_ROUTER=1 vitest tests/apollo-router",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/gateway": "2.13.2",
|
||||
"@apollo/server": "5.4.0",
|
||||
"@apollo/server": "5.5.0",
|
||||
"@apollo/subgraph": "2.13.2",
|
||||
"@aws-sdk/client-s3": "3.723.0",
|
||||
"@esm2cjs/execa": "6.1.1-cjs.1",
|
||||
|
|
@ -20,11 +19,12 @@
|
|||
"@graphql-hive/core": "workspace:*",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@hive/commerce": "workspace:*",
|
||||
"@hive/postgres": "workspace:*",
|
||||
"@hive/schema": "workspace:*",
|
||||
"@hive/server": "workspace:*",
|
||||
"@hive/service-common": "workspace:*",
|
||||
"@hive/storage": "workspace:*",
|
||||
"@theguild/federation-composition": "0.22.1",
|
||||
"@theguild/federation-composition": "0.22.2",
|
||||
"@trpc/client": "10.45.3",
|
||||
"@trpc/server": "10.45.3",
|
||||
"@types/async-retry": "1.4.8",
|
||||
|
|
@ -42,10 +42,9 @@
|
|||
"human-id": "4.1.1",
|
||||
"ioredis": "5.8.2",
|
||||
"set-cookie-parser": "2.7.1",
|
||||
"slonik": "30.4.4",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tslib": "2.8.1",
|
||||
"vitest": "4.0.9",
|
||||
"vitest": "4.1.3",
|
||||
"zod": "3.25.76"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { DatabasePool } from 'slonik';
|
||||
import {
|
||||
AccessTokenKeyContainer,
|
||||
hashPassword,
|
||||
} from '@hive/api/modules/auth/lib/supertokens-at-home/crypto';
|
||||
import { SuperTokensStore } from '@hive/api/modules/auth/providers/supertokens-store';
|
||||
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
||||
import { PostgresDatabasePool } from '@hive/postgres';
|
||||
import type { InternalApi } from '@hive/server';
|
||||
import { createNewSession } from '@hive/server/supertokens-at-home/shared';
|
||||
import { createTRPCProxyClient, httpLink } from '@trpc/client';
|
||||
|
|
@ -76,7 +76,7 @@ const tokenResponsePromise: {
|
|||
} = {};
|
||||
|
||||
export async function authenticate(
|
||||
pool: DatabasePool,
|
||||
pool: PostgresDatabasePool,
|
||||
email: string,
|
||||
oidcIntegrationId?: string,
|
||||
): Promise<{ access_token: string; refresh_token: string; supertokensUserId: string }> {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { AddressInfo } from 'node:net';
|
||||
import humanId from 'human-id';
|
||||
import setCookie from 'set-cookie-parser';
|
||||
import { sql, type DatabasePool } from 'slonik';
|
||||
import z from 'zod';
|
||||
import formDataPlugin from '@fastify/formbody';
|
||||
import { psql, type PostgresDatabasePool } from '@hive/postgres';
|
||||
import { createServer, type FastifyReply, type FastifyRequest } from '@hive/service-common';
|
||||
import { graphql } from './gql';
|
||||
import { execute } from './graphql';
|
||||
|
|
@ -157,7 +157,7 @@ const VerifyEmailMutation = graphql(`
|
|||
export async function createOIDCIntegration(args: {
|
||||
organizationId: string;
|
||||
accessToken: string;
|
||||
getPool: () => Promise<DatabasePool>;
|
||||
getPool: () => Promise<PostgresDatabasePool>;
|
||||
}) {
|
||||
const { accessToken: authToken, getPool } = args;
|
||||
const result = await execute({
|
||||
|
|
@ -192,7 +192,7 @@ export async function createOIDCIntegration(args: {
|
|||
}) + '.local';
|
||||
|
||||
const pool = await getPool();
|
||||
const query = sql`
|
||||
const query = psql`
|
||||
INSERT INTO "oidc_integration_domains" (
|
||||
"organization_id"
|
||||
, "oidc_integration_id"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { formatISO, subHours } from 'date-fns';
|
||||
import { humanId } from 'human-id';
|
||||
import { createPool, sql } from 'slonik';
|
||||
import z from 'zod';
|
||||
import { NoopLogger } from '@hive/api/modules/shared/providers/logger';
|
||||
import { createRedisClient } from '@hive/api/modules/shared/providers/redis';
|
||||
import { createPostgresDatabasePool, psql } from '@hive/postgres';
|
||||
import type { Report } from '../../packages/libraries/core/src/client/usage.js';
|
||||
import { authenticate, userEmail } from './auth';
|
||||
import {
|
||||
|
|
@ -53,7 +54,12 @@ import {
|
|||
updateTargetValidationSettings,
|
||||
} from './flow';
|
||||
import * as GraphQLSchema from './gql/graphql';
|
||||
import { ProjectType, SchemaPolicyInput, TargetAccessScope } from './gql/graphql';
|
||||
import {
|
||||
ProjectType,
|
||||
SchemaPolicyInput,
|
||||
TargetAccessScope,
|
||||
UpdateOrgRateLimitDocument,
|
||||
} from './gql/graphql';
|
||||
import { execute } from './graphql';
|
||||
import { createOIDCIntegration } from './oidc-integration.js';
|
||||
import {
|
||||
|
|
@ -68,7 +74,7 @@ import { UpdateSchemaPolicyForOrganization, UpdateSchemaPolicyForProject } from
|
|||
import { collect, CollectedOperation, legacyCollect } from './usage';
|
||||
import { generateUnique, getServiceHost, pollForEmailVerificationLink } from './utils';
|
||||
|
||||
function createConnectionPool() {
|
||||
function getPGConnectionString() {
|
||||
const pg = {
|
||||
user: ensureEnv('POSTGRES_USER'),
|
||||
password: ensureEnv('POSTGRES_PASSWORD'),
|
||||
|
|
@ -77,9 +83,13 @@ function createConnectionPool() {
|
|||
db: ensureEnv('POSTGRES_DB'),
|
||||
};
|
||||
|
||||
return createPool(
|
||||
`postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`,
|
||||
);
|
||||
return `postgres://${pg.user}:${pg.password}@${pg.host}:${pg.port}/${pg.db}?sslmode=disable`;
|
||||
}
|
||||
|
||||
function createConnectionPool() {
|
||||
return createPostgresDatabasePool({
|
||||
connectionParameters: getPGConnectionString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function createDbConnection() {
|
||||
|
|
@ -92,9 +102,9 @@ async function createDbConnection() {
|
|||
};
|
||||
}
|
||||
|
||||
export function initSeed() {
|
||||
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
|
||||
let sharedDBPoolPromise: ReturnType<typeof createDbConnection>;
|
||||
|
||||
export function initSeed() {
|
||||
function getPool() {
|
||||
if (!sharedDBPoolPromise) {
|
||||
sharedDBPoolPromise = createDbConnection();
|
||||
|
|
@ -113,7 +123,7 @@ export function initSeed() {
|
|||
|
||||
if (opts?.verifyEmail ?? true) {
|
||||
const pool = await getPool();
|
||||
await pool.query(sql`
|
||||
await pool.query(psql`
|
||||
INSERT INTO "email_verifications" ("user_identity_id", "email", "verified_at")
|
||||
VALUES (${auth.supertokensUserId}, ${email}, NOW())
|
||||
`);
|
||||
|
|
@ -122,22 +132,38 @@ export function initSeed() {
|
|||
return auth;
|
||||
}
|
||||
|
||||
async function purgeOrganizationAccessTokenById(id: string) {
|
||||
const registryAddress = await getServiceHost('server', 8082);
|
||||
const purged: { deleted: boolean } = await fetch(
|
||||
'http://' + registryAddress + '/cache/organization-access-token-cache/delete/' + id,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
).then(res => res.json());
|
||||
expect(purged.deleted).toBe(true);
|
||||
}
|
||||
|
||||
return {
|
||||
pollForEmailVerificationLink,
|
||||
getPGConnectionString,
|
||||
async purgeOIDCDomains() {
|
||||
const pool = await getPool();
|
||||
await pool.query(sql`
|
||||
await pool.query(psql`
|
||||
TRUNCATE "oidc_integration_domains"
|
||||
`);
|
||||
},
|
||||
async forgeOIDCDNSChallenge(orgSlug: string) {
|
||||
const pool = await getPool();
|
||||
|
||||
const domainChallengeId = await pool.oneFirst<string>(sql`
|
||||
const domainChallengeId = await pool
|
||||
.oneFirst(
|
||||
psql`
|
||||
SELECT "oidc_integration_domains"."id"
|
||||
FROM "oidc_integration_domains" INNER JOIN "organizations" ON "oidc_integration_domains"."organization_id" = "organizations"."id"
|
||||
WHERE "organizations"."clean_id" = ${orgSlug}
|
||||
`);
|
||||
`,
|
||||
)
|
||||
.then(z.string().parse);
|
||||
const key = `hive:oidcDomainChallenge:${domainChallengeId}`;
|
||||
|
||||
const challenge = {
|
||||
|
|
@ -164,15 +190,7 @@ export function initSeed() {
|
|||
createDbConnection,
|
||||
authenticate: doAuthenticate,
|
||||
generateEmail: () => userEmail(generateUnique()),
|
||||
async purgeOrganizationAccessTokenById(id: string) {
|
||||
const registryAddress = await getServiceHost('server', 8082);
|
||||
await fetch(
|
||||
'http://' + registryAddress + '/cache/organization-access-token-cache/delete/' + id,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
).then(res => res.json());
|
||||
},
|
||||
purgeOrganizationAccessTokenById,
|
||||
async createOwner(verifyEmail: boolean = true) {
|
||||
const ownerEmail = userEmail(generateUnique());
|
||||
const auth = await doAuthenticate(ownerEmail, {
|
||||
|
|
@ -197,6 +215,31 @@ export function initSeed() {
|
|||
|
||||
return {
|
||||
organization,
|
||||
async overrideOrgPlan(plan: 'PRO' | 'ENTERPRISE' | 'HOBBY') {
|
||||
const pool = await createConnectionPool();
|
||||
|
||||
await pool.query(psql`
|
||||
UPDATE organizations SET plan_name = ${plan} WHERE id = ${organization.id}
|
||||
`);
|
||||
|
||||
await pool.end();
|
||||
},
|
||||
async updateOrgRateLimit(newLimit: number, token = ownerToken) {
|
||||
const result = await execute({
|
||||
document: UpdateOrgRateLimitDocument,
|
||||
variables: {
|
||||
selector: {
|
||||
organizationSlug: organization.slug,
|
||||
},
|
||||
monthlyLimits: {
|
||||
operations: newLimit,
|
||||
},
|
||||
},
|
||||
authToken: token,
|
||||
}).then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
return result.updateOrgRateLimit;
|
||||
},
|
||||
async createOrganizationAccessToken(
|
||||
args: {
|
||||
permissions: Array<string>;
|
||||
|
|
@ -227,8 +270,8 @@ export function initSeed() {
|
|||
async setFeatureFlag(name: string, value: boolean | string[]) {
|
||||
const pool = await createConnectionPool();
|
||||
|
||||
await pool.query(sql`
|
||||
UPDATE organizations SET feature_flags = ${sql.jsonb({
|
||||
await pool.query(psql`
|
||||
UPDATE organizations SET feature_flags = ${psql.jsonb({
|
||||
[name]: value,
|
||||
})}
|
||||
WHERE id = ${organization.id}
|
||||
|
|
@ -239,7 +282,7 @@ export function initSeed() {
|
|||
async setDataRetention(days: number) {
|
||||
const pool = await createConnectionPool();
|
||||
|
||||
await pool.query(sql`
|
||||
await pool.query(psql`
|
||||
UPDATE organizations SET limit_retention_days = ${days} WHERE id = ${organization.id}
|
||||
`);
|
||||
|
||||
|
|
@ -304,6 +347,22 @@ export function initSeed() {
|
|||
|
||||
return members;
|
||||
},
|
||||
/** Expires tokens */
|
||||
async forceExpireTokens(tokenIds: string[]) {
|
||||
const pool = await createConnectionPool();
|
||||
const result = await pool.any(psql`
|
||||
UPDATE "organization_access_tokens"
|
||||
SET "expires_at" = NOW()
|
||||
WHERE id IN (${psql.join(tokenIds, psql.fragment`, `)}) AND organization_id = ${organization.id}
|
||||
RETURNING
|
||||
"id"
|
||||
`);
|
||||
await pool.end();
|
||||
expect(result.length).toBe(tokenIds.length);
|
||||
for (const id of tokenIds) {
|
||||
await purgeOrganizationAccessTokenById(id);
|
||||
}
|
||||
},
|
||||
async projects(token = ownerToken) {
|
||||
const projectsResult = await getOrganizationProjects(
|
||||
{ organizationSlug: organization.slug },
|
||||
|
|
@ -343,7 +402,7 @@ export function initSeed() {
|
|||
async setNativeFederation(enabled: boolean) {
|
||||
const pool = await createConnectionPool();
|
||||
|
||||
await pool.query(sql`
|
||||
await pool.query(psql`
|
||||
UPDATE projects SET native_federation = ${enabled} WHERE id = ${project.id}
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -471,3 +471,53 @@ test.concurrent(
|
|||
).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('query GraphQL API on resources after token expiration', async ({ expect }) => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const org = await createOrg();
|
||||
const project = await org.createProject(GraphQLSchema.ProjectType.Federation);
|
||||
|
||||
const result = await createOrganizationAccessToken(
|
||||
{
|
||||
organization: {
|
||||
byId: org.organization.id,
|
||||
},
|
||||
title: 'second access token',
|
||||
description: 'a description',
|
||||
resources: {
|
||||
mode: GraphQLSchema.ResourceAssignmentModeType.All,
|
||||
},
|
||||
permissions: ['organization:describe'],
|
||||
},
|
||||
ownerToken,
|
||||
).then(e => e.expectNoGraphQLErrors());
|
||||
expect(result.createOrganizationAccessToken.error).toEqual(null);
|
||||
const orgAccessToken = result.createOrganizationAccessToken.ok?.privateAccessKey;
|
||||
expect(result.createOrganizationAccessToken.ok?.createdOrganizationAccessToken.id).toBeDefined();
|
||||
|
||||
await execute({
|
||||
document: OrganizationProjectTargetQuery,
|
||||
variables: {
|
||||
organizationSlug: org.organization.slug,
|
||||
projectSlug: project.project.slug,
|
||||
targetSlug: project.target.slug,
|
||||
},
|
||||
authToken: orgAccessToken,
|
||||
}).then(e => e.expectNoGraphQLErrors());
|
||||
|
||||
await org.forceExpireTokens([
|
||||
result.createOrganizationAccessToken.ok?.createdOrganizationAccessToken.id!,
|
||||
]);
|
||||
|
||||
const expiredResult = await execute({
|
||||
document: OrganizationProjectTargetQuery,
|
||||
variables: {
|
||||
organizationSlug: org.organization.slug,
|
||||
projectSlug: project.project.slug,
|
||||
targetSlug: project.target.slug,
|
||||
},
|
||||
authToken: orgAccessToken,
|
||||
}).then(e => e.expectGraphQLErrors());
|
||||
const error = expiredResult[0];
|
||||
expect(error.message).toEqual('Invalid token provided');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -746,3 +746,69 @@ test.concurrent(
|
|||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent('query GraphQL API on resources after token expiration', async ({ expect }) => {
|
||||
const { createOrg, ownerToken } = await initSeed().createOwner();
|
||||
const org = await createOrg();
|
||||
const project1 = await org.createProject(GraphQLSchema.ProjectType.Federation);
|
||||
const project2 = await org.createProject(GraphQLSchema.ProjectType.Federation);
|
||||
|
||||
const result = await execute({
|
||||
document: CreatePersonalAccessTokenMutation,
|
||||
variables: {
|
||||
input: {
|
||||
organization: {
|
||||
byId: org.organization.id,
|
||||
},
|
||||
title: 'an access token',
|
||||
description: 'a description',
|
||||
resources: {
|
||||
mode: GraphQLSchema.ResourceAssignmentModeType.Granular,
|
||||
projects: [
|
||||
{
|
||||
projectId: project1.project.id,
|
||||
targets: { mode: GraphQLSchema.ResourceAssignmentModeType.All },
|
||||
},
|
||||
],
|
||||
},
|
||||
expirationPeriod: GraphQLSchema.TokenExpirationPeriod.OneWeek,
|
||||
permissions: ['organization:describe', 'project:describe'],
|
||||
},
|
||||
},
|
||||
authToken: ownerToken,
|
||||
}).then(e => e.expectNoGraphQLErrors());
|
||||
expect(result.createPersonalAccessToken.error).toEqual(null);
|
||||
assertNonNullish(result.createPersonalAccessToken.ok);
|
||||
const personalAccessToken = result.createPersonalAccessToken.ok.privateAccessKey;
|
||||
|
||||
const projectQuery = await execute({
|
||||
document: OrganizationProjectTargetQuery1,
|
||||
variables: {
|
||||
organizationSlug: org.organization.slug,
|
||||
projectSlug: project2.project.slug,
|
||||
targetSlug: project2.target.slug,
|
||||
},
|
||||
authToken: personalAccessToken,
|
||||
}).then(e => e.expectNoGraphQLErrors());
|
||||
expect(projectQuery).toEqual({
|
||||
organization: {
|
||||
id: expect.any(String),
|
||||
project: null,
|
||||
slug: org.organization.slug,
|
||||
},
|
||||
});
|
||||
|
||||
await org.forceExpireTokens([result.createPersonalAccessToken.ok.createdPersonalAccessToken.id]);
|
||||
|
||||
const expiredResult = await execute({
|
||||
document: OrganizationProjectTargetQuery1,
|
||||
variables: {
|
||||
organizationSlug: org.organization.slug,
|
||||
projectSlug: project2.project.slug,
|
||||
targetSlug: project2.target.slug,
|
||||
},
|
||||
authToken: personalAccessToken,
|
||||
}).then(e => e.expectGraphQLErrors());
|
||||
const error = expiredResult[0];
|
||||
expect(error.message).toEqual('Invalid token provided');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { buildASTSchema, parse } from 'graphql';
|
||||
import { createLogger } from 'graphql-yoga';
|
||||
import { sql } from 'slonik';
|
||||
import { pollFor } from 'testkit/flow';
|
||||
import { initSeed } from 'testkit/seed';
|
||||
import { getServiceHost } from 'testkit/utils';
|
||||
import z from 'zod';
|
||||
import { createHive } from '@graphql-hive/core';
|
||||
import { clickHouseInsert, clickHouseQuery } from '../../testkit/clickhouse';
|
||||
import { psql } from '@hive/postgres';
|
||||
import { clickHouseInsert } from '../../testkit/clickhouse';
|
||||
import { graphql } from '../../testkit/gql';
|
||||
import { execute } from '../../testkit/graphql';
|
||||
|
||||
|
|
@ -957,6 +958,221 @@ test('add documents to app deployment fails if document does not pass validation
|
|||
});
|
||||
});
|
||||
|
||||
test('app deployment validates documents with MCP directives when schema does not define them', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setFeatureFlag } = await createOrg();
|
||||
await setFeatureFlag('appDeployments', true);
|
||||
const { createTargetAccessToken } = await createProject();
|
||||
const token = await createTargetAccessToken({});
|
||||
|
||||
await token.publishSchema({
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
weather(location: String!): Weather
|
||||
}
|
||||
type Weather {
|
||||
temp: Float
|
||||
conditions: String
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
await execute({
|
||||
document: CreateAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-test',
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
const { addDocumentsToAppDeployment } = await execute({
|
||||
document: AddDocumentsToAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-test',
|
||||
appVersion: '1.0.0',
|
||||
documents: [
|
||||
{
|
||||
hash: 'mcp-weather',
|
||||
body: [
|
||||
'query GetWeather(',
|
||||
' $location: String! @mcpDescription(provider: "langfuse:loc") @mcpHeader(name: "X-Location")',
|
||||
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||
' weather(location: $location) { temp conditions }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
hash: 'mcp-weather-title',
|
||||
body: [
|
||||
'query GetWeatherTitle(',
|
||||
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||
') @mcpTool(name: "get_weather_title", title: "Weather Tool", meta: "{}") {',
|
||||
' weather(location: $location) { temp conditions }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
expect(addDocumentsToAppDeployment.error).toBeNull();
|
||||
});
|
||||
|
||||
test('app deployment validates documents with MCP directives when schema already defines them', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setFeatureFlag } = await createOrg();
|
||||
await setFeatureFlag('appDeployments', true);
|
||||
const { createTargetAccessToken } = await createProject();
|
||||
const token = await createTargetAccessToken({});
|
||||
|
||||
await token.publishSchema({
|
||||
sdl: /* GraphQL */ `
|
||||
scalar JSON
|
||||
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
|
||||
directive @mcpDescription(provider: String!) on VARIABLE_DEFINITION | FIELD
|
||||
directive @mcpHeader(name: String!) on VARIABLE_DEFINITION
|
||||
type Query {
|
||||
weather(location: String!): Weather
|
||||
}
|
||||
type Weather {
|
||||
temp: Float
|
||||
conditions: String
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
await execute({
|
||||
document: CreateAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-existing',
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
const { addDocumentsToAppDeployment: successResult } = await execute({
|
||||
document: AddDocumentsToAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-existing',
|
||||
appVersion: '1.0.0',
|
||||
documents: [
|
||||
{
|
||||
hash: 'mcp-weather-existing',
|
||||
body: [
|
||||
'query GetWeather(',
|
||||
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||
' weather(location: $location) { temp conditions }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
expect(successResult.error).toBeNull();
|
||||
|
||||
const { addDocumentsToAppDeployment: failResult } = await execute({
|
||||
document: AddDocumentsToAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-existing',
|
||||
appVersion: '1.0.0',
|
||||
documents: [
|
||||
{
|
||||
hash: 'mcp-weather-title',
|
||||
body: [
|
||||
'query GetWeather(',
|
||||
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||
') @mcpTool(name: "get_weather", title: "Weather Tool") {',
|
||||
' weather(location: $location) { temp conditions }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
expect(failResult.error).toEqual(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('not valid'),
|
||||
details: expect.objectContaining({
|
||||
message: expect.stringContaining('title'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('app deployment injects only missing MCP directives when schema partially defines them', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setFeatureFlag } = await createOrg();
|
||||
await setFeatureFlag('appDeployments', true);
|
||||
const { createTargetAccessToken } = await createProject();
|
||||
const token = await createTargetAccessToken({});
|
||||
|
||||
await token.publishSchema({
|
||||
sdl: /* GraphQL */ `
|
||||
directive @mcpTool(name: String!, description: String) on QUERY | MUTATION
|
||||
type Query {
|
||||
weather(location: String!): Weather
|
||||
}
|
||||
type Weather {
|
||||
temp: Float
|
||||
conditions: String
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
await execute({
|
||||
document: CreateAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-partial',
|
||||
appVersion: '1.0.0',
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
const { addDocumentsToAppDeployment } = await execute({
|
||||
document: AddDocumentsToAppDeployment,
|
||||
variables: {
|
||||
input: {
|
||||
appName: 'mcp-partial',
|
||||
appVersion: '1.0.0',
|
||||
documents: [
|
||||
{
|
||||
hash: 'mcp-partial-weather',
|
||||
body: [
|
||||
'query GetWeather(',
|
||||
' $location: String! @mcpDescription(provider: "langfuse:loc")',
|
||||
') @mcpTool(name: "get_weather", description: "Get weather") {',
|
||||
' weather(location: $location) { temp conditions }',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
authToken: token.secret,
|
||||
}).then(res => res.expectNoGraphQLErrors());
|
||||
|
||||
expect(addDocumentsToAppDeployment.error).toBeNull();
|
||||
});
|
||||
|
||||
test('add documents to app deployment fails if document contains multiple executable operation definitions', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject, setFeatureFlag } = await createOrg();
|
||||
|
|
@ -2056,12 +2272,20 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
|
|||
);
|
||||
|
||||
// insert into postgres
|
||||
const result = await conn.pool.query(sql`
|
||||
const result = await conn.pool
|
||||
.any(
|
||||
psql`
|
||||
INSERT INTO app_deployments ("target_id", "name", "version", "activated_at")
|
||||
SELECT * FROM ${sql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
|
||||
SELECT * FROM ${psql.unnest(appDeploymentRows, ['uuid', 'text', 'text', 'timestamptz'])}
|
||||
RETURNING "id", "target_id", "name", "version"
|
||||
`);
|
||||
expect(result.rowCount).toBe(1200);
|
||||
`,
|
||||
)
|
||||
.then(
|
||||
z.array(
|
||||
z.object({ id: z.string(), target_id: z.string(), name: z.string(), version: z.string() }),
|
||||
).parse,
|
||||
);
|
||||
expect(result.length).toBe(1200);
|
||||
|
||||
// insert into clickhouse and activate
|
||||
const query = `INSERT INTO app_deployments (
|
||||
|
|
@ -2071,7 +2295,7 @@ test('activeAppDeployments works for > 1000 records with a date filter (neverUse
|
|||
,"app_version"
|
||||
,"is_active"
|
||||
) VALUES
|
||||
${result.rows
|
||||
${result
|
||||
.map(
|
||||
r => `(
|
||||
'${r['target_id']}'
|
||||
|
|
|
|||
38
integration-tests/tests/api/commerce/plan.spec.ts
Normal file
38
integration-tests/tests/api/commerce/plan.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { initSeed } from '../../../testkit/seed';
|
||||
|
||||
test.concurrent(
|
||||
'should not allow HOBBY organization to use updateOrgRateLimit mutation',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { updateOrgRateLimit, overrideOrgPlan } = await createOrg();
|
||||
|
||||
await expect(updateOrgRateLimit(50000000)).rejects.toThrowError(
|
||||
'Only PRO organizations can update rate limits via API',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'should not allow ENTERPRISE organization to use updateOrgRateLimit mutation',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { updateOrgRateLimit, overrideOrgPlan } = await createOrg();
|
||||
await overrideOrgPlan('ENTERPRISE');
|
||||
|
||||
await expect(updateOrgRateLimit(50000000)).rejects.toThrowError(
|
||||
'Only PRO organizations can update rate limits via API',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'should only allow PRO organization to use updateOrgRateLimit mutation',
|
||||
async ({ expect }) => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { overrideOrgPlan, updateOrgRateLimit } = await createOrg();
|
||||
|
||||
await overrideOrgPlan('PRO');
|
||||
// Not throwing - should succeed
|
||||
await updateOrgRateLimit(50000000);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { pollFor } from 'testkit/flow';
|
||||
import { graphql } from 'testkit/gql';
|
||||
import { ResourceAssignmentModeType } from 'testkit/gql/graphql';
|
||||
import { execute } from 'testkit/graphql';
|
||||
|
|
@ -115,15 +116,19 @@ test.concurrent('cannot delete a role with members', async ({ expect }) => {
|
|||
test.concurrent('email invitation', async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { createOrg } = await seed.createOwner();
|
||||
const { inviteMember } = await createOrg();
|
||||
const { inviteMember, organization } = await createOrg();
|
||||
|
||||
const inviteEmail = seed.generateEmail();
|
||||
const invitationResult = await inviteMember(inviteEmail);
|
||||
const inviteCode = invitationResult.ok?.createdOrganizationInvitation.code;
|
||||
expect(inviteCode).toBeDefined();
|
||||
|
||||
const sentEmails = await history();
|
||||
expect(sentEmails).toContainEqual(expect.objectContaining({ to: inviteEmail }));
|
||||
await pollFor(async () => {
|
||||
const sentEmails = await history(inviteEmail);
|
||||
return sentEmails.length > 0;
|
||||
});
|
||||
const sentEmails = await history(inviteEmail);
|
||||
expect(sentEmails[0].subject).toEqual('You have been invited to join ' + organization.slug);
|
||||
});
|
||||
|
||||
test.concurrent('can not invite with role not existing in organization', async ({ expect }) => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ function filterEmailsByOrg(orgSlug: string, emails: emails.Email[]) {
|
|||
|
||||
test('rate limit approaching and reached for organization', async () => {
|
||||
const { createOrg, ownerToken, ownerEmail } = await initSeed().createOwner();
|
||||
const { createProject, organization } = await createOrg();
|
||||
const { createProject, organization, overrideOrgPlan } = await createOrg();
|
||||
await overrideOrgPlan('PRO');
|
||||
const { createTargetAccessToken, waitForRequestsCollected } = await createProject(
|
||||
ProjectType.Single,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import 'reflect-metadata';
|
||||
import { sql, type CommonQueryMethods } from 'slonik';
|
||||
/* eslint-disable no-process-env */
|
||||
import { ProjectType } from 'testkit/gql/graphql';
|
||||
import { test } from 'vitest';
|
||||
import z from 'zod';
|
||||
import { psql, type CommonQueryMethods } from '@hive/postgres';
|
||||
import { initSeed } from '../../../testkit/seed';
|
||||
|
||||
async function fetchCoordinates(db: CommonQueryMethods, target: { id: string }) {
|
||||
const result = await db.query<{
|
||||
coordinate: string;
|
||||
created_in_version_id: string;
|
||||
deprecated_in_version_id: string | null;
|
||||
}>(sql`
|
||||
const result = await db
|
||||
.any(
|
||||
psql`
|
||||
SELECT coordinate, created_in_version_id, deprecated_in_version_id
|
||||
FROM schema_coordinate_status WHERE target_id = ${target.id}
|
||||
`);
|
||||
`,
|
||||
)
|
||||
.then(
|
||||
z.object({
|
||||
coordinate: z.string(),
|
||||
created_in_version_id: z.string(),
|
||||
deprecated_in_version_id: z.string().nullable(),
|
||||
}).parse,
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
return result;
|
||||
}
|
||||
|
||||
describe.skip('schema cleanup tracker', () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import 'reflect-metadata';
|
||||
import { createPool, sql } from 'slonik';
|
||||
import { graphql } from 'testkit/gql';
|
||||
/* eslint-disable no-process-env */
|
||||
import { ProjectType } from 'testkit/gql/graphql';
|
||||
import { execute } from 'testkit/graphql';
|
||||
import { assertNonNull, getServiceHost } from 'testkit/utils';
|
||||
import z from 'zod';
|
||||
import { createPostgresDatabasePool, psql } from '@hive/postgres';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { createStorage } from '@hive/storage';
|
||||
import { createTarget, publishSchema, updateSchemaComposition } from '../../../testkit/flow';
|
||||
|
|
@ -3820,7 +3821,7 @@ test.concurrent(
|
|||
);
|
||||
|
||||
const insertLegacyVersion = async (
|
||||
pool: Awaited<ReturnType<typeof createPool>>,
|
||||
pool: Awaited<ReturnType<typeof createPostgresDatabasePool>>,
|
||||
args: {
|
||||
sdl: string;
|
||||
projectId: string;
|
||||
|
|
@ -3828,7 +3829,9 @@ const insertLegacyVersion = async (
|
|||
serviceUrl: string;
|
||||
},
|
||||
) => {
|
||||
const logId = await pool.oneFirst<string>(sql`
|
||||
const logId = await pool
|
||||
.oneFirst(
|
||||
psql`
|
||||
INSERT INTO schema_log
|
||||
(
|
||||
author,
|
||||
|
|
@ -3854,9 +3857,13 @@ const insertLegacyVersion = async (
|
|||
'PUSH'
|
||||
)
|
||||
RETURNING id
|
||||
`);
|
||||
`,
|
||||
)
|
||||
.then(z.string().parse);
|
||||
|
||||
const versionId = await pool.oneFirst<string>(sql`
|
||||
const versionId = await pool
|
||||
.oneFirst(
|
||||
psql`
|
||||
INSERT INTO schema_versions
|
||||
(
|
||||
is_composable,
|
||||
|
|
@ -3870,9 +3877,11 @@ const insertLegacyVersion = async (
|
|||
${logId}
|
||||
)
|
||||
RETURNING "id"
|
||||
`);
|
||||
`,
|
||||
)
|
||||
.then(z.string().parse);
|
||||
|
||||
await pool.query(sql`
|
||||
await pool.query(psql`
|
||||
INSERT INTO
|
||||
schema_version_to_log
|
||||
(version_id, action_id)
|
||||
|
|
@ -3886,7 +3895,7 @@ const insertLegacyVersion = async (
|
|||
test.concurrent(
|
||||
'service url change from legacy to new version is displayed correctly',
|
||||
async ({ expect }) => {
|
||||
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
|
||||
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
|
||||
try {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
|
|
@ -3899,7 +3908,9 @@ test.concurrent(
|
|||
// We need to seed a legacy entry in the database
|
||||
|
||||
const conn = connectionString();
|
||||
pool = await createPool(conn);
|
||||
pool = await createPostgresDatabasePool({
|
||||
connectionParameters: conn,
|
||||
});
|
||||
|
||||
const sdl = 'type Query { ping: String! }';
|
||||
|
||||
|
|
@ -3950,7 +3961,7 @@ test.concurrent(
|
|||
test.concurrent(
|
||||
'service url change from legacy to legacy version is displayed correctly',
|
||||
async ({ expect }) => {
|
||||
let pool: Awaited<ReturnType<typeof createPool>> | undefined;
|
||||
let pool: Awaited<ReturnType<typeof createPostgresDatabasePool>> | undefined;
|
||||
try {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
|
|
@ -3963,7 +3974,7 @@ test.concurrent(
|
|||
// We need to seed a legacy entry in the database
|
||||
|
||||
const conn = connectionString();
|
||||
pool = await createPool(conn);
|
||||
pool = await createPostgresDatabasePool({ connectionParameters: conn });
|
||||
|
||||
const sdl = 'type Query { ping: String! }';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { pollFor, readTokenInfo } from 'testkit/flow';
|
||||
import { ProjectType } from 'testkit/gql/graphql';
|
||||
import { createTokenStorage } from '@hive/storage';
|
||||
import { generateToken } from '@hive/tokens';
|
||||
import { initSeed } from '../../testkit/seed';
|
||||
|
||||
test.concurrent('deleting a token should clear the cache', async () => {
|
||||
|
|
@ -144,3 +146,59 @@ test.concurrent(
|
|||
`);
|
||||
},
|
||||
);
|
||||
|
||||
test.concurrent(
|
||||
'regression: reading existing token with "last_used_at" from pg database (and not redis cache) does not raise an exception',
|
||||
async ({ expect }) => {
|
||||
const seed = initSeed();
|
||||
const { createOrg } = await seed.createOwner();
|
||||
const { createProject, organization } = await createOrg();
|
||||
const { project, target } = await createProject();
|
||||
|
||||
const tokenStorage = await createTokenStorage(seed.getPGConnectionString(), 1);
|
||||
|
||||
try {
|
||||
const token = generateToken();
|
||||
|
||||
// create new token so it does not yet exist in redis cache
|
||||
const record = await tokenStorage.createToken({
|
||||
name: 'foo',
|
||||
organization: organization.id,
|
||||
project: project.id,
|
||||
target: target.id,
|
||||
scopes: [],
|
||||
token: token.hash,
|
||||
tokenAlias: token.alias,
|
||||
});
|
||||
|
||||
// touch the token so it has a date
|
||||
await tokenStorage.touchTokens({ tokens: [{ token: record.token, date: new Date() }] });
|
||||
const result = await readTokenInfo(token.secret).then(res => res.expectNoGraphQLErrors());
|
||||
expect(result.tokenInfo).toMatchInlineSnapshot(`
|
||||
{
|
||||
__typename: TokenInfo,
|
||||
hasOrganizationDelete: false,
|
||||
hasOrganizationIntegrations: false,
|
||||
hasOrganizationMembers: false,
|
||||
hasOrganizationRead: false,
|
||||
hasOrganizationSettings: false,
|
||||
hasProjectAlerts: false,
|
||||
hasProjectDelete: false,
|
||||
hasProjectOperationsStoreRead: false,
|
||||
hasProjectOperationsStoreWrite: false,
|
||||
hasProjectRead: false,
|
||||
hasProjectSettings: false,
|
||||
hasTargetDelete: false,
|
||||
hasTargetRead: false,
|
||||
hasTargetRegistryRead: false,
|
||||
hasTargetRegistryWrite: false,
|
||||
hasTargetSettings: false,
|
||||
hasTargetTokensRead: false,
|
||||
hasTargetTokensWrite: false,
|
||||
}
|
||||
`);
|
||||
} finally {
|
||||
await tokenStorage.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MaybePromise } from 'slonik/dist/src/types';
|
||||
import { ProjectType } from 'testkit/gql/graphql';
|
||||
import { initSeed } from 'testkit/seed';
|
||||
import { getServiceHost } from 'testkit/utils';
|
||||
import { execa } from '@esm2cjs/execa';
|
||||
|
||||
describe('Apollo Router Integration', () => {
|
||||
const getAvailablePort = () =>
|
||||
new Promise<number>(resolve => {
|
||||
const server = createServer();
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === 'object') {
|
||||
const port = address.port;
|
||||
server.close(() => resolve(port));
|
||||
} else {
|
||||
throw new Error('Could not get available port');
|
||||
}
|
||||
});
|
||||
});
|
||||
function defer(deferFn: () => MaybePromise<void>) {
|
||||
return {
|
||||
async [Symbol.asyncDispose]() {
|
||||
return deferFn();
|
||||
},
|
||||
};
|
||||
}
|
||||
it('fetches the supergraph and sends usage reports', async () => {
|
||||
const { createOrg } = await initSeed().createOwner();
|
||||
const { createProject } = await createOrg();
|
||||
const { createTargetAccessToken, createCdnAccess, target, waitForOperationsCollected } =
|
||||
await createProject(ProjectType.Federation);
|
||||
const writeToken = await createTargetAccessToken({});
|
||||
|
||||
// Publish Schema
|
||||
const publishSchemaResult = await writeToken
|
||||
.publishSchema({
|
||||
author: 'Arda',
|
||||
commit: 'abc123',
|
||||
sdl: /* GraphQL */ `
|
||||
type Query {
|
||||
me: User
|
||||
}
|
||||
type User {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
`,
|
||||
service: 'users',
|
||||
url: 'https://federation-demo.theguild.workers.dev/users',
|
||||
})
|
||||
.then(r => r.expectNoGraphQLErrors());
|
||||
|
||||
expect(publishSchemaResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
|
||||
|
||||
const cdnAccessResult = await createCdnAccess();
|
||||
|
||||
const usageAddress = await getServiceHost('usage', 8081);
|
||||
|
||||
const routerBinPath = join(__dirname, '../../../target/debug/router');
|
||||
if (!existsSync(routerBinPath)) {
|
||||
throw new Error(
|
||||
`Apollo Router binary not found at path: ${routerBinPath}, make sure to build it first with 'cargo build'`,
|
||||
);
|
||||
}
|
||||
|
||||
const routerPort = await getAvailablePort();
|
||||
const routerConfigContent = `
|
||||
supergraph:
|
||||
listen: 0.0.0.0:${routerPort}
|
||||
plugins:
|
||||
hive.usage: {}
|
||||
`.trim();
|
||||
const routerConfigPath = join(tmpdir(), `apollo-router-config-${Date.now()}.yaml`);
|
||||
writeFileSync(routerConfigPath, routerConfigContent, 'utf-8');
|
||||
|
||||
const cdnEndpoint = await getServiceHost('server', 8082).then(
|
||||
v => `http://${v}/artifacts/v1/${target.id}`,
|
||||
);
|
||||
const routerProc = execa(routerBinPath, ['--dev', '--config', routerConfigPath], {
|
||||
all: true,
|
||||
env: {
|
||||
HIVE_CDN_ENDPOINT: cdnEndpoint,
|
||||
HIVE_CDN_KEY: cdnAccessResult.secretAccessToken,
|
||||
HIVE_ENDPOINT: `http://${usageAddress}`,
|
||||
HIVE_TOKEN: writeToken.secret,
|
||||
},
|
||||
});
|
||||
let log = '';
|
||||
await new Promise((resolve, reject) => {
|
||||
routerProc.catch(err => {
|
||||
if (!err.isCanceled) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
const routerProcOut = routerProc.all;
|
||||
if (!routerProcOut) {
|
||||
return reject(new Error('No stdout from Apollo Router process'));
|
||||
}
|
||||
routerProcOut.on('data', data => {
|
||||
log += data.toString();
|
||||
if (log.includes('GraphQL endpoint exposed at')) {
|
||||
resolve(true);
|
||||
}
|
||||
process.stdout.write(log);
|
||||
});
|
||||
});
|
||||
|
||||
await using _ = defer(() => {
|
||||
rmSync(routerConfigPath);
|
||||
routerProc.cancel();
|
||||
});
|
||||
|
||||
const url = `http://localhost:${routerPort}/`;
|
||||
async function sendOperation(i: number) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `
|
||||
query Query${i} {
|
||||
me {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json();
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
me: {
|
||||
id: '1',
|
||||
name: 'Ada Lovelace',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const cnt = 1000;
|
||||
const jobs = [];
|
||||
for (let i = 0; i < cnt; i++) {
|
||||
if (i % 100 === 0) {
|
||||
await new Promise(res => setTimeout(res, 500));
|
||||
}
|
||||
jobs.push(sendOperation(i));
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
await waitForOperationsCollected(cnt);
|
||||
});
|
||||
});
|
||||
|
|
@ -74,7 +74,6 @@ test('update-retention script skips gracefully when no env vars are set', async
|
|||
delete process.env.CLICKHOUSE_TTL_HOURLY_MV_TABLES;
|
||||
delete process.env.CLICKHOUSE_TTL_MINUTELY_MV_TABLES;
|
||||
|
||||
vi.resetModules();
|
||||
const { updateRetention } = await import(
|
||||
'../../../packages/migrations/src/scripts/update-retention'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,8 +37,6 @@ export default defineConfig({
|
|||
},
|
||||
setupFiles,
|
||||
testTimeout: 90_000,
|
||||
exclude: process.env.TEST_APOLLO_ROUTER
|
||||
? defaultExclude
|
||||
: [...defaultExclude, 'tests/apollo-router/**'],
|
||||
exclude: defaultExclude,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
27
package.json
27
package.json
|
|
@ -14,7 +14,7 @@
|
|||
"private": true,
|
||||
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d",
|
||||
"engines": {
|
||||
"node": ">=24.13",
|
||||
"node": ">=24.14.1",
|
||||
"pnpm": ">=10.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"prerelease": "pnpm build:libraries",
|
||||
"prettier": "prettier --cache --write --list-different --ignore-unknown \"**/*\"",
|
||||
"release": "pnpm build:libraries && changeset publish",
|
||||
"release:version": "changeset version && pnpm --filter hive-apollo-router-plugin sync-cargo-file && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
|
||||
"release:version": "changeset version && pnpm build:libraries && pnpm --filter @graphql-hive/cli oclif:readme",
|
||||
"seed:app-deployments": "tsx scripts/seed-app-deployments.mts",
|
||||
"seed:insights": "tsx scripts/seed-insights.mts",
|
||||
"seed:org": "tsx scripts/seed-organization.mts",
|
||||
|
|
@ -54,7 +54,6 @@
|
|||
"test:e2e:local": "CYPRESS_BASE_URL=http://localhost:3000 RUN_AGAINST_LOCAL_SERVICES=1 cypress open --browser chrome",
|
||||
"test:e2e:open": "CYPRESS_BASE_URL=$HIVE_APP_BASE_URL cypress open",
|
||||
"test:integration": "cd integration-tests && pnpm test:integration",
|
||||
"test:integration:apollo-router": "cd integration-tests && pnpm test:integration:apollo-router",
|
||||
"typecheck": "pnpm run -r --filter '!hive' typecheck",
|
||||
"upload-sourcemaps": "./scripts/upload-sourcemaps.sh",
|
||||
"workspace": "pnpm run --filter $1 $2"
|
||||
|
|
@ -72,7 +71,7 @@
|
|||
"@graphql-codegen/typescript-operations": "5.0.2",
|
||||
"@graphql-codegen/typescript-resolvers": "5.1.0",
|
||||
"@graphql-codegen/urql-introspection": "3.0.1",
|
||||
"@graphql-eslint/eslint-plugin": "4.4.0",
|
||||
"@graphql-eslint/eslint-plugin": "3.20.1",
|
||||
"@graphql-inspector/cli": "6.0.6",
|
||||
"@graphql-inspector/core": "7.1.2",
|
||||
"@graphql-inspector/patch": "0.1.3",
|
||||
|
|
@ -82,10 +81,10 @@
|
|||
"@parcel/watcher": "2.5.1",
|
||||
"@sentry/cli": "2.40.0",
|
||||
"@swc/core": "1.13.5",
|
||||
"@theguild/eslint-config": "0.13.1",
|
||||
"@theguild/federation-composition": "0.22.1",
|
||||
"@theguild/eslint-config": "0.12.1",
|
||||
"@theguild/federation-composition": "0.22.2",
|
||||
"@theguild/prettier-config": "2.0.7",
|
||||
"@types/node": "24.10.9",
|
||||
"@types/node": "24.12.2",
|
||||
"bob-the-bundler": "7.0.1",
|
||||
"cypress": "13.17.0",
|
||||
"dotenv": "16.4.7",
|
||||
|
|
@ -109,7 +108,7 @@
|
|||
"turbo": "2.5.8",
|
||||
"typescript": "5.7.3",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "4.0.9"
|
||||
"vitest": "4.1.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides.esbuild": "To address CVE: https://github.com/graphql-hive/console/security/dependabot/259",
|
||||
|
|
@ -126,9 +125,9 @@
|
|||
"overrides.fast-xml-parser@5.x.x": "address https://github.com/graphql-hive/console/security/dependabot/576",
|
||||
"overrides.minimatch@10.x.x": "address https://github.com/graphql-hive/console/security/dependabot/505",
|
||||
"overrides.qs@<6.14.2": "address https://github.com/graphql-hive/console/security/dependabot/499",
|
||||
"overrides.dompurify@3.x.x": "address https://github.com/graphql-hive/console/security/dependabot/536",
|
||||
"overrides.ajv@8.x.x": "address https://github.com/graphql-hive/console/security/dependabot/507",
|
||||
"overrides.yauzl@2.x.x": "address https://github.com/graphql-hive/console/security/dependabot/542",
|
||||
"overrides.path-to-regexp@0.x.x": "address https://github.com/graphql-hive/console/security/dependabot/619",
|
||||
"overrides": {
|
||||
"esbuild": "0.25.9",
|
||||
"csstype": "3.1.2",
|
||||
|
|
@ -161,18 +160,18 @@
|
|||
"minimatch@3.x.x": "^3.1.3",
|
||||
"minimatch@4.x.x": "^4.2.4",
|
||||
"qs@<6.14.2": "^6.14.2",
|
||||
"dompurify@3.x.x": "^3.3.2",
|
||||
"ajv@8.x.x": "^8.18.0",
|
||||
"yauzl@2.x.x": "^3.2.1",
|
||||
"glob@10.x.x": "^10.5.0"
|
||||
"glob@10.x.x": "^10.5.0",
|
||||
"path-to-regexp@0.x.x": "^0.1.13"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",
|
||||
"@apollo/federation@0.38.1": "patches/@apollo__federation@0.38.1.patch",
|
||||
"@theguild/editor@1.2.5": "patches/@theguild__editor@1.2.5.patch",
|
||||
"eslint@8.57.1": "patches/eslint@8.57.1.patch",
|
||||
"@graphql-eslint/eslint-plugin@3.20.1": "patches/@graphql-eslint__eslint-plugin@3.20.1.patch",
|
||||
"got@14.4.7": "patches/got@14.4.7.patch",
|
||||
"slonik@30.4.4": "patches/slonik@30.4.4.patch",
|
||||
"@oclif/core@3.26.6": "patches/@oclif__core@3.26.6.patch",
|
||||
"oclif": "patches/oclif.patch",
|
||||
"graphiql": "patches/graphiql.patch",
|
||||
|
|
@ -181,7 +180,9 @@
|
|||
"p-cancelable@4.0.1": "patches/p-cancelable@4.0.1.patch",
|
||||
"bentocache": "patches/bentocache.patch",
|
||||
"@graphql-codegen/schema-ast": "patches/@graphql-codegen__schema-ast.patch",
|
||||
"@fastify/vite": "patches/@fastify__vite.patch"
|
||||
"@fastify/vite": "patches/@fastify__vite.patch",
|
||||
"@slonik/pg-driver": "patches/@slonik__pg-driver.patch",
|
||||
"slonik": "patches/slonik.patch"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"msw"
|
||||
|
|
|
|||
3
packages/internal/postgres/README.md
Normal file
3
packages/internal/postgres/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Internal Postgres Client
|
||||
|
||||
This is a lightweight abstraction on top of Slonik, that sets up some things for ease of usage.
|
||||
19
packages/internal/postgres/package.json
Normal file
19
packages/internal/postgres/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "@hive/postgres",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"slonik": "48.13.2",
|
||||
"slonik-interceptor-query-logging": "48.13.2",
|
||||
"slonik-sql-tag-raw": "48.13.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hive/service-common": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
export function createConnectionString(config: {
|
||||
export type PostgresConnectionParamaters = {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string | undefined;
|
||||
user: string;
|
||||
db: string;
|
||||
ssl: boolean;
|
||||
}) {
|
||||
};
|
||||
|
||||
/** Create a Postgres Connection String */
|
||||
export function createConnectionString(config: PostgresConnectionParamaters) {
|
||||
// prettier-ignore
|
||||
const encodedUser = encodeURIComponent(config.user);
|
||||
const encodedPassword =
|
||||
17
packages/internal/postgres/src/index.ts
Normal file
17
packages/internal/postgres/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export {
|
||||
PostgresDatabasePool,
|
||||
createPostgresDatabasePool,
|
||||
type CommonQueryMethods,
|
||||
} from './postgres-database-pool';
|
||||
export { type PostgresConnectionParamaters, createConnectionString } from './connection-string';
|
||||
export { psql, type TaggedTemplateLiteralInvocation } from './psql';
|
||||
export {
|
||||
UniqueIntegrityConstraintViolationError,
|
||||
ForeignKeyIntegrityConstraintViolationError,
|
||||
type PrimitiveValueExpression,
|
||||
type SerializableValue,
|
||||
type Interceptor,
|
||||
type Query,
|
||||
type QueryContext,
|
||||
} from 'slonik';
|
||||
export { toDate } from './utils';
|
||||
91
packages/internal/postgres/src/pg-pool-bridge.ts
Normal file
91
packages/internal/postgres/src/pg-pool-bridge.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import type { PoolClient } from 'pg';
|
||||
import { sql, type DatabasePool, type DatabasePoolConnection } from 'slonik';
|
||||
import { raw } from 'slonik-sql-tag-raw';
|
||||
|
||||
/**
|
||||
* Bridge {slonik.DatabasePool} to an {pg.Pool} for usage with Postgraphile Workers.
|
||||
*
|
||||
* This is a very very pragmatic approach, since slonik moved away from using {pg.Pool} internally.
|
||||
* https://github.com/gajus/slonik/issues/768
|
||||
**/
|
||||
export class PgPoolBridge {
|
||||
constructor(private pool: DatabasePool) {}
|
||||
|
||||
end(): never {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
async connect(): Promise<PoolClient> {
|
||||
const pgClientAvailableP = Promise.withResolvers<any>();
|
||||
const pgClientReleasedP = Promise.withResolvers<void>();
|
||||
|
||||
// slonik connect works in a way where the client is acquired for the callback handler closure only.
|
||||
// It is released once the Promise returned from the handler resolves.
|
||||
// We need to be a bit creative to support the "pg.Pool" API and obviously
|
||||
// trust graphile-workers to call the `release` method on our fake {pg.Client} :)
|
||||
// so the client is released back to the pool
|
||||
void this.pool.connect(async client => {
|
||||
pgClientAvailableP.resolve(new PgClientBridge(client, pgClientReleasedP.resolve));
|
||||
await pgClientReleasedP.promise;
|
||||
});
|
||||
|
||||
return pgClientAvailableP.promise;
|
||||
}
|
||||
|
||||
/** Some of graphile-workers logic just calls the `query` method on {pg.Pool} - without first acquiring a connection. */
|
||||
query(query: unknown, values?: unknown, callback?: unknown): any {
|
||||
// not used, but just in case so we can catch it in the future...
|
||||
if (typeof callback !== 'undefined') {
|
||||
throw new Error('PgClientBridge.query: callback not supported');
|
||||
}
|
||||
|
||||
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
|
||||
throw new Error('PgClientBridge.query: unsupported query input');
|
||||
}
|
||||
|
||||
if (typeof query === 'string') {
|
||||
return this.pool.query(sql.unsafe`${raw(query, values as any)}`);
|
||||
}
|
||||
|
||||
return this.pool.query(sql.unsafe`${raw((query as any).text as any, (query as any).values)}`);
|
||||
}
|
||||
|
||||
on(): this {
|
||||
// Note: we can skip setting up event handlers, as graphile workers is only setting up error handlers to avoid uncaught exceptions
|
||||
// For us, the error handlers are already set up by slonik
|
||||
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
|
||||
return this;
|
||||
}
|
||||
|
||||
removeListener(): this {
|
||||
// Note: we can skip tearing down event handlers, since we ship setting them up in the first place
|
||||
// https://github.com/graphile/worker/blob/5650fbc4406fa3ce197b2ab582e08fd20974e50c/src/lib.ts#L351-L359
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class PgClientBridge {
|
||||
constructor(
|
||||
private connection: DatabasePoolConnection,
|
||||
/** This is invoked for again releasing the connection. */
|
||||
public release: () => void,
|
||||
) {}
|
||||
|
||||
query(query: unknown, values?: unknown, callback?: unknown): any {
|
||||
if (typeof callback !== 'undefined') {
|
||||
throw new Error('PgClientBridge.query: callback not supported');
|
||||
}
|
||||
|
||||
if ((typeof query !== 'string' && typeof query !== 'object') || !query) {
|
||||
throw new Error('PgClientBridge.query: unsupported query input');
|
||||
}
|
||||
|
||||
if (typeof query === 'string') {
|
||||
return this.connection.query(sql.unsafe`${raw(query, values as any)}`);
|
||||
}
|
||||
|
||||
return this.connection.query(
|
||||
sql.unsafe`${raw((query as any).text as any, (query as any).values)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
223
packages/internal/postgres/src/postgres-database-pool.ts
Normal file
223
packages/internal/postgres/src/postgres-database-pool.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import type { Pool } from 'pg';
|
||||
import {
|
||||
createPool,
|
||||
createTypeParserPreset,
|
||||
type DatabasePool,
|
||||
type Interceptor,
|
||||
type PrimitiveValueExpression,
|
||||
type QuerySqlToken,
|
||||
type CommonQueryMethods as SlonikCommonQueryMethods,
|
||||
} from 'slonik';
|
||||
import { createQueryLoggingInterceptor } from 'slonik-interceptor-query-logging';
|
||||
import { context, SpanKind, SpanStatusCode, trace } from '@hive/service-common';
|
||||
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
||||
import { createConnectionString, type PostgresConnectionParamaters } from './connection-string';
|
||||
import { PgPoolBridge } from './pg-pool-bridge';
|
||||
|
||||
const tracer = trace.getTracer('storage');
|
||||
|
||||
export interface CommonQueryMethods {
|
||||
exists<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<boolean>;
|
||||
any<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>>;
|
||||
maybeOne<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<null | StandardSchemaV1.InferOutput<T>>;
|
||||
query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void>;
|
||||
oneFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
|
||||
one<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<StandardSchemaV1.InferOutput<T>>;
|
||||
anyFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>>;
|
||||
maybeOneFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>;
|
||||
}
|
||||
|
||||
export class PostgresDatabasePool implements CommonQueryMethods {
|
||||
constructor(private pool: DatabasePool) {}
|
||||
|
||||
getPgPoolCompat(): Pool {
|
||||
return new PgPoolBridge(this.pool) as any;
|
||||
}
|
||||
|
||||
/** Retrieve the raw Slonik instance. Refrain from using this API. */
|
||||
getSlonikPool() {
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
async exists<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<boolean> {
|
||||
return this.pool.exists(sql, values);
|
||||
}
|
||||
|
||||
async any<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<ReadonlyArray<StandardSchemaV1.InferOutput<T>>> {
|
||||
return this.pool.any(sql, values);
|
||||
}
|
||||
|
||||
async maybeOne<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<null | StandardSchemaV1.InferOutput<T>> {
|
||||
return this.pool.maybeOne(sql, values);
|
||||
}
|
||||
|
||||
async query(sql: QuerySqlToken<any>, values?: PrimitiveValueExpression[]): Promise<void> {
|
||||
await this.pool.query(sql, values);
|
||||
}
|
||||
|
||||
async oneFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
|
||||
return await this.pool.oneFirst(sql, values);
|
||||
}
|
||||
|
||||
async maybeOneFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<null | StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]> {
|
||||
return await this.pool.maybeOneFirst(sql, values);
|
||||
}
|
||||
|
||||
async one<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<StandardSchemaV1.InferOutput<T>> {
|
||||
return await this.pool.one(sql, values);
|
||||
}
|
||||
|
||||
async anyFirst<T extends StandardSchemaV1>(
|
||||
sql: QuerySqlToken<T>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<
|
||||
ReadonlyArray<StandardSchemaV1.InferOutput<T>[keyof StandardSchemaV1.InferOutput<T>]>
|
||||
> {
|
||||
return await this.pool.anyFirst(sql, values);
|
||||
}
|
||||
|
||||
async transaction<T = void>(
|
||||
name: string,
|
||||
handler: (methods: CommonQueryMethods) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const span = tracer.startSpan(`PG Transaction: ${name}`, {
|
||||
kind: SpanKind.INTERNAL,
|
||||
});
|
||||
|
||||
return context.with(trace.setSpan(context.active(), span), async () => {
|
||||
return await this.pool.transaction(async methods => {
|
||||
try {
|
||||
return await handler({
|
||||
exists: methods.exists,
|
||||
any: methods.any,
|
||||
maybeOne: methods.maybeOne,
|
||||
async query(
|
||||
sql: QuerySqlToken<any>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): Promise<void> {
|
||||
await methods.query(sql, values);
|
||||
},
|
||||
oneFirst: methods.oneFirst,
|
||||
maybeOneFirst: methods.maybeOneFirst,
|
||||
anyFirst: methods.anyFirst,
|
||||
one: methods.one,
|
||||
});
|
||||
} catch (err) {
|
||||
span.setAttribute('error', 'true');
|
||||
|
||||
if (err instanceof Error) {
|
||||
span.setAttribute('error.type', err.name);
|
||||
span.setAttribute('error.message', err.message);
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
end(): Promise<void> {
|
||||
return this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
const dbInterceptors: Interceptor[] = [createQueryLoggingInterceptor()];
|
||||
|
||||
const typeParsers = [
|
||||
...createTypeParserPreset().filter(parser => parser.name !== 'int8'),
|
||||
{
|
||||
name: 'int8',
|
||||
parse: (value: string) => parseInt(value, 10),
|
||||
},
|
||||
];
|
||||
|
||||
export async function createPostgresDatabasePool(args: {
|
||||
connectionParameters: PostgresConnectionParamaters | string;
|
||||
maximumPoolSize?: number;
|
||||
additionalInterceptors?: Interceptor[];
|
||||
statementTimeout?: number;
|
||||
}) {
|
||||
const connectionString =
|
||||
typeof args.connectionParameters === 'string'
|
||||
? args.connectionParameters
|
||||
: createConnectionString(args.connectionParameters);
|
||||
const pool = await createPool(connectionString, {
|
||||
interceptors: dbInterceptors.concat(args.additionalInterceptors ?? []),
|
||||
typeParsers,
|
||||
captureStackTrace: false,
|
||||
maximumPoolSize: args.maximumPoolSize,
|
||||
idleTimeout: 30000,
|
||||
statementTimeout: args.statementTimeout,
|
||||
});
|
||||
|
||||
function interceptError<K extends Exclude<keyof SlonikCommonQueryMethods, 'transaction'>>(
|
||||
methodName: K,
|
||||
) {
|
||||
const original: SlonikCommonQueryMethods[K] = pool[methodName];
|
||||
|
||||
function interceptor(
|
||||
this: any,
|
||||
sql: QuerySqlToken<any>,
|
||||
values?: PrimitiveValueExpression[],
|
||||
): any {
|
||||
return (original as any).call(this, sql, values).catch((error: any) => {
|
||||
error.sql = sql.sql;
|
||||
error.values = sql.values || values;
|
||||
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
pool[methodName] = interceptor as any;
|
||||
}
|
||||
|
||||
interceptError('one');
|
||||
interceptError('many');
|
||||
|
||||
return new PostgresDatabasePool(pool);
|
||||
}
|
||||
23
packages/internal/postgres/src/psql.ts
Normal file
23
packages/internal/postgres/src/psql.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createSqlTag, QuerySqlToken, type SqlTag, type ValueExpression } from 'slonik';
|
||||
import { z } from 'zod';
|
||||
import { StandardSchemaV1 } from '@standard-schema/spec';
|
||||
|
||||
const tag = createSqlTag();
|
||||
|
||||
interface TemplateStringsArray extends ReadonlyArray<string> {
|
||||
readonly raw: readonly string[];
|
||||
}
|
||||
|
||||
type CallableTag<
|
||||
T extends StandardSchemaV1<unknown, unknown> = StandardSchemaV1<unknown, unknown>,
|
||||
> = (template: TemplateStringsArray, ...values: ValueExpression[]) => QuerySqlToken<T>;
|
||||
|
||||
export type TaggedTemplateLiteralInvocation = QuerySqlToken<any>;
|
||||
|
||||
function psqlFn(template: TemplateStringsArray, ...values: ValueExpression[]) {
|
||||
return tag.type(z.unknown())(template, ...values);
|
||||
}
|
||||
|
||||
Object.assign(psqlFn, createSqlTag());
|
||||
|
||||
export const psql = psqlFn as any as SqlTag<any> & CallableTag;
|
||||
5
packages/internal/postgres/src/utils.ts
Normal file
5
packages/internal/postgres/src/utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { psql } from './psql';
|
||||
|
||||
export function toDate(date: Date) {
|
||||
return psql`to_timestamp(${date.getTime() / 1000})`;
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"@graphql-hive/logger": "^1.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/server": "5.4.0",
|
||||
"@apollo/server": "5.5.0",
|
||||
"@as-integrations/express4": "1.1.2",
|
||||
"@graphql-tools/schema": "10.0.25",
|
||||
"@types/express": "4.17.21",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"graphql": "16.9.0",
|
||||
"graphql-ws": "5.16.1",
|
||||
"nock": "14.0.10",
|
||||
"vitest": "4.0.9",
|
||||
"vitest": "4.1.3",
|
||||
"ws": "8.18.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
"@oclif/core": "3.26.6",
|
||||
"@oclif/plugin-help": "6.2.36",
|
||||
"@oclif/plugin-update": "4.7.16",
|
||||
"@theguild/federation-composition": "0.22.1",
|
||||
"@theguild/federation-composition": "0.22.2",
|
||||
"cli-table3": "0.6.5",
|
||||
"colors": "1.4.0",
|
||||
"env-ci": "7.3.0",
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export default abstract class BaseCommand<T extends typeof Command> extends Comm
|
|||
ensure<
|
||||
TKey extends ValidConfigurationKeys,
|
||||
TArgs extends {
|
||||
[key in TKey]: GetConfigurationValueType<TKey>;
|
||||
[_key in TKey]: GetConfigurationValueType<TKey>;
|
||||
},
|
||||
>({
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@
|
|||
"graphql": "16.9.0",
|
||||
"nock": "14.0.10",
|
||||
"tslib": "2.8.1",
|
||||
"vitest": "4.0.9"
|
||||
"vitest": "4.1.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org",
|
||||
|
|
|
|||
|
|
@ -202,7 +202,7 @@ type OptionalWhenFalse<T, KCond extends keyof T, KExcluded extends keyof T> =
|
|||
// untouched by default or when true
|
||||
| T
|
||||
// when false, make KExcluded optional
|
||||
| (Omit<T, KExcluded> & { [P in KCond]: false } & { [P in KExcluded]?: T[KExcluded] });
|
||||
| (Omit<T, KExcluded> & { [_P in KCond]: false } & { [_P in KExcluded]?: T[KExcluded] });
|
||||
|
||||
export type HivePluginOptions = OptionalWhenFalse<
|
||||
{
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export function addProperty<T, K extends string, V>(
|
|||
value: V,
|
||||
obj: T,
|
||||
): T & {
|
||||
[k in K]: V;
|
||||
[_k in K]: V;
|
||||
};
|
||||
export function addProperty<T, K extends string, V>(
|
||||
key: K,
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@apollo/composition": "2.13.2",
|
||||
"@types/node": "24.10.9",
|
||||
"@types/node": "24.12.2",
|
||||
"esbuild": "0.25.9",
|
||||
"fastify": "5.8.1",
|
||||
"fastify": "5.8.5",
|
||||
"graphql": "16.9.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,39 @@
|
|||
# @graphql-hive/laboratory
|
||||
|
||||
## 0.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7989](https://github.com/graphql-hive/console/pull/7989)
|
||||
[`863f920`](https://github.com/graphql-hive/console/commit/863f920b86505a3d84c9001fef1c3e8a723bdca9)
|
||||
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Enhanced behavior when no collection
|
||||
exists and the user attempts to save an operation, along with the ability to edit the collection
|
||||
name.
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7963](https://github.com/graphql-hive/console/pull/7963)
|
||||
[`4a8bd4f`](https://github.com/graphql-hive/console/commit/4a8bd4fd1b4fbb34076e97d06ed1341432de451d)
|
||||
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Implemented functionality that allows
|
||||
to have multiple queries in same operation while working only with focused one (run button, query
|
||||
builder)
|
||||
|
||||
- [#7892](https://github.com/graphql-hive/console/pull/7892)
|
||||
[`fab4b03`](https://github.com/graphql-hive/console/commit/fab4b03ace2ff20759bbcd33465d00a5cbbc4c97)
|
||||
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - Hive Laboratory renders Hive Router
|
||||
query plan if included in response extensions
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#7888](https://github.com/graphql-hive/console/pull/7888)
|
||||
[`574a5d8`](https://github.com/graphql-hive/console/commit/574a5d823e71ca1d0628897a73e2fab1d0d5bfe0)
|
||||
Thanks [@mskorokhodov](https://github.com/mskorokhodov)! - If schema introspection isn't provided
|
||||
as property to Laboratory, lab will start interval to fetch schema every second.
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/laboratory/components",
|
||||
"utils": "@/laboratory/lib/utils",
|
||||
"ui": "@/laboratory/components/ui",
|
||||
"lib": "@/laboratory/lib",
|
||||
"hooks": "@/laboratory/hooks"
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils",
|
||||
"ui": "src/components/ui",
|
||||
"lib": "src/lib",
|
||||
"hooks": "src/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@graphql-hive/laboratory",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "./dist/hive-laboratory.cjs.js",
|
||||
|
|
@ -29,20 +29,23 @@
|
|||
"peerDependencies": {
|
||||
"@tanstack/react-form": "^1.23.8",
|
||||
"date-fns": "^4.1.0",
|
||||
"graphql-ws": "^6.0.6",
|
||||
"lucide-react": "^0.548.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tslib": "^2.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@graphql-tools/url-loader": "^9.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dagrejs/dagre": "^1.1.8",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
|
@ -76,7 +79,7 @@
|
|||
"@tanstack/router-plugin": "^1.154.13",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/node": "24.10.9",
|
||||
"@types/node": "24.12.2",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
|
|
@ -96,8 +99,7 @@
|
|||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-ws": "^6.0.6",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.548.0",
|
||||
"lz-string": "^1.5.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
|
|
@ -112,6 +114,7 @@
|
|||
"react-shadow": "^20.6.0",
|
||||
"rollup-plugin-typescript2": "^0.36.0",
|
||||
"sonner": "^2.0.7",
|
||||
"subscriptions-transport-ws": "^0.11.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-scoped-preflight": "^3.5.7",
|
||||
|
|
|
|||
519
packages/libraries/laboratory/src/components/flow.tsx
Normal file
519
packages/libraries/laboratory/src/components/flow.tsx
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react';
|
||||
import { LucideProps, MaximizeIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import dagre from '@dagrejs/dagre';
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
title: string;
|
||||
next?: string[];
|
||||
icon?: (props: LucideProps) => React.ReactNode;
|
||||
content?: (props: { node: FlowNode }) => React.ReactNode;
|
||||
headerSuffix?: (props: { node: FlowNode }) => React.ReactNode;
|
||||
children?: FlowNode[];
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export interface FlowGraphInternal extends FlowNode {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export function orthogonalPoints(from: Point, to: Point, t = 0.5): [Point, Point, Point, Point] {
|
||||
const midX = from.x + (to.x - from.x) * t;
|
||||
|
||||
return [from, { x: midX, y: from.y }, { x: midX, y: to.y }, to];
|
||||
}
|
||||
|
||||
export function roundedOrthogonalPath(
|
||||
[p0, p1, p2, p3]: [Point, Point, Point, Point],
|
||||
radius = 12,
|
||||
): string {
|
||||
const r1 = Math.min(radius, Math.abs(p1.x - p0.x), Math.abs(p2.y - p1.y));
|
||||
const r2 = Math.min(radius, Math.abs(p2.y - p1.y), Math.abs(p3.x - p2.x));
|
||||
|
||||
const p1a = {
|
||||
x: p1.x - Math.sign(p1.x - p0.x) * r1,
|
||||
y: p1.y,
|
||||
};
|
||||
|
||||
const p1b = {
|
||||
x: p1.x,
|
||||
y: p1.y + Math.sign(p2.y - p1.y) * r1,
|
||||
};
|
||||
|
||||
const p2a = {
|
||||
x: p2.x,
|
||||
y: p2.y - Math.sign(p2.y - p1.y) * r2,
|
||||
};
|
||||
|
||||
const p2b = {
|
||||
x: p2.x + Math.sign(p3.x - p2.x) * r2,
|
||||
y: p2.y,
|
||||
};
|
||||
|
||||
return [
|
||||
`M ${p0.x} ${p0.y}`,
|
||||
`L ${p1a.x} ${p1a.y}`,
|
||||
`Q ${p1.x} ${p1.y} ${p1b.x} ${p1b.y}`,
|
||||
`L ${p2a.x} ${p2a.y}`,
|
||||
`Q ${p2.x} ${p2.y} ${p2b.x} ${p2b.y}`,
|
||||
`L ${p3.x} ${p3.y}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.2;
|
||||
const MAX_SCALE = 3;
|
||||
const ZOOM_STEP = 0.02;
|
||||
|
||||
export const Flow = (props: {
|
||||
nodes: FlowNode[];
|
||||
margin?: number;
|
||||
gapX?: number;
|
||||
gapY?: number;
|
||||
onGraphLayout?: (graph: dagre.graphlib.Graph) => void;
|
||||
disableBackground?: boolean;
|
||||
disableGestures?: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
isChild?: boolean;
|
||||
}) => {
|
||||
const [isCanvasActive, setIsCanvasActive] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const panStartRef = useRef<Point | null>(null);
|
||||
const [view, setView] = useState<{ x: number; y: number; scale: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
});
|
||||
const [nodeSizes, setNodeSizes] = useState<Record<string, { width: number; height: number }>>({});
|
||||
const [nodes, edges, graphSize] = useMemo(() => {
|
||||
if (Object.keys(nodeSizes).length === 0) {
|
||||
return [
|
||||
props.nodes.map(node => ({ ...node, x: 0, y: 0, width: 0, height: 0, isCluster: false })),
|
||||
[],
|
||||
{ width: 0, height: 0 },
|
||||
];
|
||||
}
|
||||
|
||||
const result = new dagre.graphlib.Graph({
|
||||
compound: true,
|
||||
})
|
||||
.setGraph({
|
||||
rankdir: 'LR',
|
||||
align: 'UL',
|
||||
ranksep: props.gapX ?? 48,
|
||||
nodesep: props.gapY ?? 48,
|
||||
marginx: props.margin ?? 32,
|
||||
marginy: props.margin ?? 64,
|
||||
graph: 'tight-tree',
|
||||
})
|
||||
.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const node of props.nodes) {
|
||||
result.setNode(node.id, {
|
||||
width: nodeSizes[node.id]?.width,
|
||||
height: nodeSizes[node.id]?.height,
|
||||
});
|
||||
}
|
||||
|
||||
for (const node of props.nodes) {
|
||||
if (node.next) {
|
||||
for (const next of node.next) {
|
||||
result.setEdge(node.id, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(result);
|
||||
|
||||
props.onGraphLayout?.(result);
|
||||
|
||||
const graph = result.graph();
|
||||
|
||||
return [
|
||||
props.nodes.map(node => {
|
||||
const graphNode = result.node(node.id);
|
||||
|
||||
return {
|
||||
...node,
|
||||
x: graphNode?.x ?? 0,
|
||||
y: graphNode?.y ?? 0,
|
||||
width: graphNode?.width ?? 0,
|
||||
height: graphNode?.height ?? 0,
|
||||
};
|
||||
}),
|
||||
result.edges().map(edge => {
|
||||
return {
|
||||
from: edge.v,
|
||||
to: edge.w,
|
||||
};
|
||||
}),
|
||||
{ width: graph.width, height: graph.height },
|
||||
];
|
||||
}, [nodeSizes, props.nodes, props.margin, props.gapX]);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: WheelEvent<HTMLDivElement>) => {
|
||||
if (props.disableGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const pointerX = event.clientX - bounds.left;
|
||||
const pointerY = event.clientY - bounds.top;
|
||||
|
||||
setView(prev => {
|
||||
const zoomFactor = Math.exp(-event.deltaY * ZOOM_STEP);
|
||||
const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, prev.scale * zoomFactor));
|
||||
const ratio = scale / prev.scale;
|
||||
const x = pointerX - (pointerX - prev.x) * ratio;
|
||||
const y = pointerY - (pointerY - prev.y) * ratio;
|
||||
|
||||
return { x, y, scale };
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
},
|
||||
[props.disableGestures],
|
||||
);
|
||||
|
||||
const stopPanning = useCallback(() => {
|
||||
setIsPanning(false);
|
||||
panStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.composedPath().includes(containerRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.disableGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setIsPanning(true);
|
||||
panStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
function handleMouseUp() {
|
||||
stopPanning();
|
||||
setIsCanvasActive(false);
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
},
|
||||
[props.disableGestures],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>) => {
|
||||
if (props.disableGestures || !isPanning || !panStartRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - panStartRef.current.x;
|
||||
const deltaY = event.clientY - panStartRef.current.y;
|
||||
panStartRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
setView(prev => ({
|
||||
...prev,
|
||||
x: prev.x + deltaX,
|
||||
y: prev.y + deltaY,
|
||||
}));
|
||||
},
|
||||
[isPanning, props.disableGestures],
|
||||
);
|
||||
|
||||
const fitInView = useCallback(() => {
|
||||
const { width, height } = graphSize;
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: containerWidth, height: containerHeight } = container.getBoundingClientRect();
|
||||
|
||||
console.log({
|
||||
container,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
const scale = Math.min(
|
||||
MAX_SCALE,
|
||||
Math.max(MIN_SCALE, Math.min(containerWidth / width, containerHeight / height)),
|
||||
);
|
||||
|
||||
setView(prev => ({
|
||||
...prev,
|
||||
scale,
|
||||
x: containerWidth / 2 - (width * scale) / 2,
|
||||
y: containerHeight / 2 - (height * scale) / 2,
|
||||
}));
|
||||
}, [graphSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.disableGestures || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = containerRef.current;
|
||||
|
||||
const preventNativeGesture = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
element.addEventListener('gesturestart', preventNativeGesture, { passive: false });
|
||||
element.addEventListener('gesturechange', preventNativeGesture, { passive: false });
|
||||
element.addEventListener('gestureend', preventNativeGesture, { passive: false });
|
||||
element.addEventListener('wheel', preventNativeGesture, { passive: false });
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('gesturestart', preventNativeGesture);
|
||||
element.removeEventListener('gesturechange', preventNativeGesture);
|
||||
element.removeEventListener('gestureend', preventNativeGesture);
|
||||
element.removeEventListener('wheel', preventNativeGesture);
|
||||
};
|
||||
}, [props.disableGestures]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.disableGestures) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preventBrowserZoomHotkeys = (event: KeyboardEvent) => {
|
||||
if (!isCanvasActive || (!event.metaKey && !event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (['+', '-', '=', '0'].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', preventBrowserZoomHotkeys);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', preventBrowserZoomHotkeys);
|
||||
};
|
||||
}, [isCanvasActive, props.disableGestures]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'bg-background relative h-full w-full touch-none',
|
||||
{
|
||||
'cursor-grab': !props.disableGestures && !isPanning,
|
||||
'cursor-grabbing': !props.disableGestures && isPanning,
|
||||
},
|
||||
props.containerClassName,
|
||||
)}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => {
|
||||
stopPanning();
|
||||
setIsCanvasActive(false);
|
||||
}}
|
||||
onMouseEnter={() => setIsCanvasActive(true)}
|
||||
>
|
||||
{!props.disableBackground && (
|
||||
<div className="bg-size-[16px_16px] absolute inset-0 h-full w-full bg-[radial-gradient(hsl(var(--border))_1px,transparent_1px)] opacity-50" />
|
||||
)}
|
||||
<div
|
||||
className={cn('relative', props.className)}
|
||||
style={{
|
||||
width: graphSize.width,
|
||||
height: graphSize.height,
|
||||
transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{nodes.map(node => {
|
||||
const hasFollowers = !!node.next?.length;
|
||||
const hasPrevious = nodes.some(n => n.next?.includes(node.id));
|
||||
const content = node.content ? node.content({ node }) : null;
|
||||
const hasContent = !!content;
|
||||
const hasChildren = !!node.children?.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
ref={ref => {
|
||||
if (ref && !nodeSizes[node.id]) {
|
||||
setNodeSizes(prev => ({
|
||||
...prev,
|
||||
[node.id]: { width: ref.clientWidth, height: ref.clientHeight },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'bg-card transition-color absolute flex grid min-w-72 grid-cols-1 grid-rows-1 justify-start gap-2 rounded-lg border p-2 text-sm shadow-sm',
|
||||
{
|
||||
'w-72': !hasChildren,
|
||||
'grid-rows-[auto_1fr]': hasContent || hasChildren,
|
||||
'grid-rows-[auto_auto_1fr]': hasContent && hasChildren,
|
||||
'rounded-xl border-dashed bg-transparent shadow-none': hasChildren,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
left: node.x - node.width / 2,
|
||||
top: node.y - node.height / 2,
|
||||
minWidth: Math.min(Math.max(node.width, 256), node.maxWidth ?? Infinity),
|
||||
minHeight: node.height,
|
||||
maxWidth: node.maxWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{node.icon ? node.icon({ className: 'size-4 text-secondary-foreground' }) : null}
|
||||
<span className="font-medium">{node.title}</span>
|
||||
<div className="ml-auto">
|
||||
{node.headerSuffix ? node.headerSuffix({ node }) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-secondary w-full rounded-sm p-2 empty:hidden">
|
||||
{node.content ? node.content({ node }) : null}
|
||||
</div>
|
||||
{!!node.children?.length && (
|
||||
<div className="size-full rounded-sm">
|
||||
<Flow
|
||||
nodes={node.children}
|
||||
margin={0}
|
||||
gapX={24}
|
||||
gapY={16}
|
||||
onGraphLayout={graph => {
|
||||
const { width, height } = graph.graph();
|
||||
|
||||
setNodeSizes(prev => ({
|
||||
...prev,
|
||||
[node.id]: {
|
||||
width: width + 20,
|
||||
height: node.height + height + 4,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
disableBackground
|
||||
disableGestures
|
||||
className="bg-transparent"
|
||||
isChild
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasFollowers && (
|
||||
<div className="border-border bg-background absolute left-full top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
|
||||
)}
|
||||
{hasPrevious && (
|
||||
<div className="border-border bg-background absolute left-0 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 transition-all" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<svg
|
||||
className="pointer-events-none absolute left-0 top-0 -z-10"
|
||||
style={{ width: graphSize.width, height: graphSize.height }}
|
||||
>
|
||||
{edges.filter(Boolean).map(edge => {
|
||||
const fromNode = nodes.find(node => node.id === edge.from);
|
||||
const toNode = nodes.find(node => node.id === edge.to);
|
||||
|
||||
if (!fromNode || !toNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<path
|
||||
key={edge.from + edge.to}
|
||||
className="stroke-border animate-dash transition-color animate-[dash_500ms_linear_infinite] fill-none stroke-2 [stroke-dasharray:12_8]"
|
||||
d={roundedOrthogonalPath(
|
||||
orthogonalPoints(
|
||||
{
|
||||
x: fromNode.x + fromNode.width / 2,
|
||||
y: fromNode.y,
|
||||
},
|
||||
{
|
||||
x: toNode.x - toNode.width / 2,
|
||||
y: toNode.y,
|
||||
},
|
||||
),
|
||||
4,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
{!props.isChild && (
|
||||
<div className="absolute left-4 top-4 z-10 flex items-center gap-2">
|
||||
<div className="bg-card grid w-96 grid-cols-[1fr_auto_auto] items-center gap-2 rounded-lg border p-2 shadow-sm">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setView(prev => ({ ...prev, scale: prev.scale - ZOOM_STEP }))}
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom out</TooltipContent>
|
||||
</Tooltip>
|
||||
<Slider
|
||||
value={[view.scale]}
|
||||
onValueChange={value => setView(prev => ({ ...prev, scale: value[0] }))}
|
||||
min={MIN_SCALE}
|
||||
max={MAX_SCALE}
|
||||
step={ZOOM_STEP}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setView(prev => ({ ...prev, scale: prev.scale + ZOOM_STEP }))}
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Zoom in</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-6!" />
|
||||
<Button variant="ghost" size="sm" onClick={fitInView}>
|
||||
<MaximizeIcon className="size-4" />
|
||||
Fit in view
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
SearchIcon,
|
||||
TextAlignStartIcon,
|
||||
} from 'lucide-react';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
|
||||
import { toast } from 'sonner';
|
||||
import type { LaboratoryOperation } from '../../lib/operations';
|
||||
import {
|
||||
getFieldByPath,
|
||||
|
|
@ -40,6 +40,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '..
|
|||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '../ui/input-group';
|
||||
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useLaboratory } from './context';
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ export const BuilderArgument = (props: {
|
|||
path: string[];
|
||||
isReadOnly?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
}) => {
|
||||
const {
|
||||
schema,
|
||||
|
|
@ -89,9 +91,18 @@ export const BuilderArgument = (props: {
|
|||
}
|
||||
|
||||
if (checked) {
|
||||
addArgToActiveOperation(props.path.join('.'), props.field.name, schema);
|
||||
addArgToActiveOperation(
|
||||
props.path.join('.'),
|
||||
props.field.name,
|
||||
schema,
|
||||
props.operationName,
|
||||
);
|
||||
} else {
|
||||
deleteArgFromActiveOperation(props.path.join('.'), props.field.name);
|
||||
deleteArgFromActiveOperation(
|
||||
props.path.join('.'),
|
||||
props.field.name,
|
||||
props.operationName,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -111,6 +122,7 @@ export const BuilderScalarField = (props: {
|
|||
isSearchActive?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
disableChildren?: boolean;
|
||||
|
|
@ -140,16 +152,18 @@ export const BuilderScalarField = (props: {
|
|||
);
|
||||
|
||||
const isInQuery = useMemo(() => {
|
||||
return isPathInQuery(operation?.query ?? '', path);
|
||||
}, [operation?.query, path]);
|
||||
return isPathInQuery(operation?.query ?? '', path, props.operationName);
|
||||
}, [operation?.query, path, props.operationName]);
|
||||
|
||||
const args = useMemo(() => {
|
||||
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
|
||||
}, [props.field]);
|
||||
|
||||
const hasArgs = useMemo(() => {
|
||||
return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name));
|
||||
}, [operation?.query, args, path]);
|
||||
return args.some(arg =>
|
||||
isArgInQuery(operation?.query ?? '', path, arg.name, props.operationName),
|
||||
);
|
||||
}, [operation?.query, args, path, props.operationName]);
|
||||
|
||||
const shouldHighlight = useMemo(() => {
|
||||
const splittedName = splitIdentifier(props.field.name);
|
||||
|
|
@ -185,9 +199,9 @@ export const BuilderScalarField = (props: {
|
|||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
addPathToActiveOperation(path, props.operationName);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
deletePathFromActiveOperation(path, props.operationName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -237,9 +251,9 @@ export const BuilderScalarField = (props: {
|
|||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
addPathToActiveOperation(path, props.operationName);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
deletePathFromActiveOperation(path, props.operationName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -321,9 +335,9 @@ export const BuilderScalarField = (props: {
|
|||
disabled={activeTab?.type !== 'operation'}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
addPathToActiveOperation(props.path.join('.'));
|
||||
addPathToActiveOperation(props.path.join('.'), props.operationName);
|
||||
} else {
|
||||
deletePathFromActiveOperation(props.path.join('.'));
|
||||
deletePathFromActiveOperation(props.path.join('.'), props.operationName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -352,6 +366,7 @@ export const BuilderObjectField = (props: {
|
|||
isSearchActive?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
disableChildren?: boolean;
|
||||
|
|
@ -441,9 +456,9 @@ export const BuilderObjectField = (props: {
|
|||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
addPathToActiveOperation(path, props.operationName);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
deletePathFromActiveOperation(path, props.operationName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -492,9 +507,9 @@ export const BuilderObjectField = (props: {
|
|||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
addPathToActiveOperation(path, props.operationName);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
deletePathFromActiveOperation(path, props.operationName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -564,6 +579,7 @@ export const BuilderObjectField = (props: {
|
|||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={props.searchValue}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -583,6 +599,7 @@ export const BuilderField = (props: {
|
|||
forcedOpenPaths?: Set<string> | null;
|
||||
isSearchActive?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
isReadOnly?: boolean;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
|
|
@ -609,6 +626,7 @@ export const BuilderField = (props: {
|
|||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={props.searchValue}
|
||||
label={props.label}
|
||||
disableChildren={props.disableChildren}
|
||||
|
|
@ -627,6 +645,7 @@ export const BuilderField = (props: {
|
|||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={props.searchValue}
|
||||
label={props.label}
|
||||
disableChildren={props.disableChildren}
|
||||
|
|
@ -651,6 +670,7 @@ export const BuilderSearchResults = (props: {
|
|||
mode: BuilderSearchResultMode;
|
||||
isReadOnly: boolean;
|
||||
operation: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
searchValue: string;
|
||||
schema: GraphQLSchema;
|
||||
tab: OperationTypeNode;
|
||||
|
|
@ -675,6 +695,7 @@ export const BuilderSearchResults = (props: {
|
|||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={props.searchValue}
|
||||
disableChildren
|
||||
label={
|
||||
|
|
@ -726,6 +747,7 @@ export const BuilderSearchResults = (props: {
|
|||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={props.searchValue}
|
||||
/>
|
||||
);
|
||||
|
|
@ -734,6 +756,7 @@ export const BuilderSearchResults = (props: {
|
|||
|
||||
export const Builder = (props: {
|
||||
operation?: LaboratoryOperation | null;
|
||||
operationName?: string | null;
|
||||
isReadOnly?: boolean;
|
||||
}) => {
|
||||
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory();
|
||||
|
|
@ -791,8 +814,6 @@ export const Builder = (props: {
|
|||
});
|
||||
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
|
||||
|
||||
console.log(searchResult);
|
||||
|
||||
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
|
||||
const forcedOpenPaths =
|
||||
isSearchActive && deferredSearchValue.includes('.')
|
||||
|
|
@ -818,6 +839,8 @@ export const Builder = (props: {
|
|||
const restoreEndpoint = useCallback(() => {
|
||||
setEndpointValue(endpoint ?? '');
|
||||
setEndpoint(defaultEndpoint ?? '');
|
||||
|
||||
toast.success('Endpoint restored to default');
|
||||
}, [defaultEndpoint, setEndpointValue]);
|
||||
|
||||
return (
|
||||
|
|
@ -853,14 +876,18 @@ export const Builder = (props: {
|
|||
</InputGroupAddon>
|
||||
{defaultEndpoint && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton className="rounded-full" size="icon-xs" onClick={restoreEndpoint}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InputGroupButton
|
||||
className="rounded-full"
|
||||
size="icon-xs"
|
||||
onClick={restoreEndpoint}
|
||||
>
|
||||
<RotateCcwIcon className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore default endpoint</TooltipContent>
|
||||
</Tooltip>
|
||||
</InputGroupButton>
|
||||
</InputGroupButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore default endpoint</TooltipContent>
|
||||
</Tooltip>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
|
@ -975,6 +1002,7 @@ export const Builder = (props: {
|
|||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
|
|
@ -1011,6 +1039,7 @@ export const Builder = (props: {
|
|||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
|
|
@ -1047,6 +1076,7 @@ export const Builder = (props: {
|
|||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
operationName={props.operationName}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CheckIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
FolderPlusIcon,
|
||||
PencilIcon,
|
||||
SearchIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from '@/components/ui/input-group';
|
||||
import { TooltipTrigger } from '@radix-ui/react-tooltip';
|
||||
import type { LaboratoryCollection, LaboratoryCollectionOperation } from '../../lib/collections';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
|
@ -44,6 +52,7 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
|||
addOperation,
|
||||
setActiveOperation,
|
||||
deleteCollection,
|
||||
updateCollection,
|
||||
deleteOperationFromCollection,
|
||||
addTab,
|
||||
setActiveTab,
|
||||
|
|
@ -51,67 +60,155 @@ export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
|
|||
} = useLaboratory();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedName, setEditedName] = useState(props.collection.name);
|
||||
|
||||
const hasActiveOperation = useMemo(() => {
|
||||
return props.collection.operations.some(operation => operation.id === activeOperation?.id);
|
||||
}, [props.collection.operations, activeOperation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveOperation) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [hasActiveOperation]);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-background group sticky top-0 w-full justify-start px-2"
|
||||
size="sm"
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
{props.collection.name}
|
||||
{checkPermissions?.('collections:delete') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{isEditing ? (
|
||||
<InputGroup className="!bg-accent/50 h-8 border-none">
|
||||
<InputGroupAddon className="pl-2.5">
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
autoFocus
|
||||
defaultValue={editedName}
|
||||
className="!pl-1.5 font-medium"
|
||||
onChange={e => setEditedName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
updateCollection(props.collection.id, {
|
||||
name: editedName,
|
||||
});
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditedName(props.collection.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
className="p-1!"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
updateCollection(props.collection.id, {
|
||||
name: editedName,
|
||||
});
|
||||
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon />
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
className="p-1!"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
|
||||
setIsEditing(false);
|
||||
setEditedName(props.collection.name);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-background !hover:bg-accent/50 group sticky top-0 w-full justify-start px-2"
|
||||
size="sm"
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
{props.collection.name}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{checkPermissions?.('collections:update') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground hover:text-destructive p-1! pr-0! ml-auto opacity-0 transition-opacity group-hover:opacity-100"
|
||||
className="text-muted-foreground p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
<PencilIcon />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete collection?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{props.collection.name} will be permanently deleted. All operations in this
|
||||
collection will be deleted as well.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{checkPermissions?.('collections:delete') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="link"
|
||||
className="text-muted-foreground hover:text-destructive p-1! pr-0! opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteCollection(props.collection.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete collection?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{props.collection.name} will be permanently deleted. All operations in
|
||||
this collection will be deleted as well.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
deleteCollection(props.collection.id);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete collection</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
|
||||
{isOpen &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { OperationDefinitionNode, parse } from 'graphql';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import { MonacoGraphQLAPI } from 'monaco-graphql/esm/api.js';
|
||||
import { initializeMode } from 'monaco-graphql/initializeMode';
|
||||
import MonacoEditor, { loader } from '@monaco-editor/react';
|
||||
import { useLaboratory } from './context';
|
||||
|
|
@ -121,46 +132,62 @@ export type EditorProps = React.ComponentProps<typeof MonacoEditor> & {
|
|||
uri?: monaco.Uri;
|
||||
variablesUri?: monaco.Uri;
|
||||
extraLibs?: string[];
|
||||
onOperationNameChange?: (operationName: string | null) => void;
|
||||
};
|
||||
|
||||
const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
||||
const id = useId();
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const { introspection, endpoint, theme } = useLaboratory();
|
||||
const wantsJson = props.language === 'json' || props.defaultLanguage === 'json';
|
||||
const [typescriptReady, setTypescriptReady] = useState(!!monaco.languages.typescript);
|
||||
const [jsonReady, setJsonReady] = useState(
|
||||
monaco.languages.getLanguages().some(language => language.id === 'json'),
|
||||
);
|
||||
const apiRef = useRef<MonacoGraphQLAPI | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (introspection) {
|
||||
const api = initializeMode({
|
||||
schemas: [
|
||||
if (apiRef.current) {
|
||||
apiRef.current.setSchemaConfig([
|
||||
{
|
||||
introspectionJSON: introspection,
|
||||
uri: `schema_${endpoint}.graphql`,
|
||||
},
|
||||
],
|
||||
diagnosticSettings:
|
||||
props.uri && props.variablesUri
|
||||
? {
|
||||
validateVariablesJSON: {
|
||||
[props.uri.toString()]: [props.variablesUri.toString()],
|
||||
},
|
||||
jsonDiagnosticSettings: {
|
||||
allowComments: true, // allow json, parse with a jsonc parser to make requests
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
]);
|
||||
} else {
|
||||
apiRef.current = initializeMode({
|
||||
schemas: [
|
||||
{
|
||||
introspectionJSON: introspection,
|
||||
uri: `schema_${endpoint}.graphql`,
|
||||
},
|
||||
],
|
||||
diagnosticSettings:
|
||||
props.uri && props.variablesUri
|
||||
? {
|
||||
validateVariablesJSON: {
|
||||
[props.uri.toString()]: [props.variablesUri.toString()],
|
||||
},
|
||||
jsonDiagnosticSettings: {
|
||||
allowComments: true, // allow json, parse with a jsonc parser to make requests
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
api.setCompletionSettings({
|
||||
__experimental__fillLeafsOnComplete: true,
|
||||
});
|
||||
apiRef.current.setCompletionSettings({
|
||||
__experimental__fillLeafsOnComplete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [introspection, props.uri?.toString(), props.variablesUri?.toString()]);
|
||||
}, [endpoint, introspection, props.uri?.toString(), props.variablesUri?.toString()]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async function () {
|
||||
if (!props.extraLibs?.length) {
|
||||
return;
|
||||
if (wantsJson && !jsonReady) {
|
||||
await import('monaco-editor/esm/vs/language/json/monaco.contribution');
|
||||
setJsonReady(true);
|
||||
}
|
||||
|
||||
if (!monaco.languages.typescript) {
|
||||
|
|
@ -168,6 +195,10 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
|||
setTypescriptReady(true);
|
||||
}
|
||||
|
||||
if (!props.extraLibs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = monaco.languages.typescript;
|
||||
|
||||
if (!ts) {
|
||||
|
|
@ -197,7 +228,7 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
|||
})),
|
||||
);
|
||||
})();
|
||||
}, [id, props.extraLibs]);
|
||||
}, [id, jsonReady, props.extraLibs, wantsJson]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
|
@ -211,19 +242,124 @@ const EditorInner = forwardRef<EditorHandle, EditorProps>((props, ref) => {
|
|||
[],
|
||||
);
|
||||
|
||||
const setupDecorationsHandler = useCallback(
|
||||
(editor: monaco.editor.IStandaloneCodeEditor) => {
|
||||
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | null = null;
|
||||
|
||||
const handler = () => {
|
||||
decorationsCollection?.clear();
|
||||
|
||||
try {
|
||||
const value = editor.getValue();
|
||||
const doc = parse(value);
|
||||
|
||||
const definition = doc.definitions.find(definition => {
|
||||
if (definition.kind !== 'OperationDefinition') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!definition.loc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursorPosition = editor.getPosition();
|
||||
|
||||
if (cursorPosition) {
|
||||
return (
|
||||
definition.loc.startToken.line <= cursorPosition.lineNumber &&
|
||||
definition.loc.endToken.line >= cursorPosition.lineNumber
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (definition?.loc) {
|
||||
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
|
||||
|
||||
if (definition.loc.startToken.line > 1) {
|
||||
decorations.push({
|
||||
range: new monaco.Range(
|
||||
0,
|
||||
0,
|
||||
definition.loc.startToken.line - 1,
|
||||
definition.loc.startToken.column,
|
||||
),
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
inlineClassName: 'inactive-line',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const lineCount = editor.getModel()?.getLineCount() ?? 0;
|
||||
const lastLineMaxColumn = editor.getModel()?.getLineMaxColumn(lineCount) ?? 0;
|
||||
|
||||
if (definition.loc.endToken.line < lineCount) {
|
||||
decorations.push({
|
||||
range: new monaco.Range(
|
||||
definition.loc.endToken.line + 1,
|
||||
definition.loc.endToken.column,
|
||||
lineCount,
|
||||
lastLineMaxColumn,
|
||||
),
|
||||
options: {
|
||||
isWholeLine: true,
|
||||
inlineClassName: 'inactive-line',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
decorationsCollection = editor.createDecorationsCollection(decorations);
|
||||
|
||||
props.onOperationNameChange?.(
|
||||
(definition as OperationDefinitionNode).name?.value ?? null,
|
||||
);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
editor.onDidChangeCursorPosition(handler);
|
||||
|
||||
handler();
|
||||
},
|
||||
[props.onOperationNameChange],
|
||||
);
|
||||
|
||||
const handleMount = useCallback(
|
||||
(editor: monaco.editor.IStandaloneCodeEditor) => {
|
||||
editorRef.current = editor;
|
||||
setupDecorationsHandler(editor);
|
||||
},
|
||||
[setupDecorationsHandler],
|
||||
);
|
||||
|
||||
const recentCursorPosition = useRef<{ lineNumber: number; column: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recentCursorPosition.current = editorRef.current?.getPosition() ?? null;
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && recentCursorPosition.current) {
|
||||
editorRef.current.setPosition(recentCursorPosition.current);
|
||||
recentCursorPosition.current = null;
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
if (!typescriptReady && props.language === 'typescript') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!jsonReady && wantsJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
<MonacoEditor
|
||||
className="size-full"
|
||||
{...props}
|
||||
theme={theme === 'dark' ? 'hive-laboratory-dark' : 'hive-laboratory-light'}
|
||||
onMount={editor => {
|
||||
editorRef.current = editor;
|
||||
}}
|
||||
onMount={handleMount}
|
||||
loading={null}
|
||||
options={{
|
||||
...props.options,
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ const LaboratoryContent = () => {
|
|||
<div className="w-full">
|
||||
<Tabs />
|
||||
</div>
|
||||
<div className="bg-card flex-1 overflow-hidden">{contentNode}</div>
|
||||
<div className="bg-card relative flex-1 overflow-hidden">{contentNode}</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
|
@ -514,7 +514,10 @@ export const Laboratory = (
|
|||
const pluginsApi = usePlugins(props);
|
||||
const testsApi = useTests(props);
|
||||
const tabsApi = useTabs(props);
|
||||
const endpointApi = useEndpoint(props);
|
||||
const endpointApi = useEndpoint({
|
||||
...props,
|
||||
settingsApi,
|
||||
});
|
||||
const collectionsApi = useCollections({
|
||||
...props,
|
||||
tabsApi,
|
||||
|
|
@ -623,7 +626,7 @@ export const Laboratory = (
|
|||
[],
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
|
|
@ -642,160 +645,9 @@ export const Laboratory = (
|
|||
className={cn('hive-laboratory bg-background size-full', props.theme, {
|
||||
'fixed inset-0 z-50': isFullScreen,
|
||||
})}
|
||||
ref={containerRef}
|
||||
ref={setContainer}
|
||||
>
|
||||
<Toaster richColors closeButton position="top-right" theme={props.theme} />
|
||||
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update endpoint</DialogTitle>
|
||||
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="update-endpoint-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void updateEndpointForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<updateEndpointForm.Field name="endpoint">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter endpoint"
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</updateEndpointForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="update-endpoint-form">
|
||||
Update endpoint
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PreflightPromptModal
|
||||
open={isPreflightPromptModalOpen}
|
||||
onOpenChange={setIsPreflightPromptModalOpen}
|
||||
{...preflightPromptModalProps}
|
||||
/>
|
||||
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-collection-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addCollectionForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addCollectionForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addCollectionForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-collection-form">
|
||||
Add collection
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add test</DialogTitle>
|
||||
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-test-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addTestForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addTestForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the test"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addTestForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-test-form">
|
||||
Add test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<LaboratoryProvider
|
||||
{...props}
|
||||
|
|
@ -809,7 +661,7 @@ export const Laboratory = (
|
|||
{...collectionsApi}
|
||||
{...operationsApi}
|
||||
{...historyApi}
|
||||
container={containerRef.current}
|
||||
container={container}
|
||||
openAddCollectionDialog={openAddCollectionDialog}
|
||||
openUpdateEndpointDialog={openUpdateEndpointDialog}
|
||||
openAddTestDialog={openAddTestDialog}
|
||||
|
|
@ -819,6 +671,157 @@ export const Laboratory = (
|
|||
isFullScreen={isFullScreen}
|
||||
checkPermissions={checkPermissions}
|
||||
>
|
||||
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update endpoint</DialogTitle>
|
||||
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="update-endpoint-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void updateEndpointForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<updateEndpointForm.Field name="endpoint">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter endpoint"
|
||||
autoComplete="off"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</updateEndpointForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="update-endpoint-form">
|
||||
Update endpoint
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PreflightPromptModal
|
||||
open={isPreflightPromptModalOpen}
|
||||
onOpenChange={setIsPreflightPromptModalOpen}
|
||||
{...preflightPromptModalProps}
|
||||
/>
|
||||
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-collection-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addCollectionForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addCollectionForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addCollectionForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-collection-form">
|
||||
Add collection
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add test</DialogTitle>
|
||||
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
id="add-test-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void addTestForm.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<addTestForm.Field name="name">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the test"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</addTestForm.Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="add-test-form">
|
||||
Add test
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<LaboratoryContent />
|
||||
</LaboratoryProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlignLeftIcon,
|
||||
BookmarkIcon,
|
||||
CircleCheckIcon,
|
||||
CircleXIcon,
|
||||
|
|
@ -7,6 +8,9 @@ import {
|
|||
FileTextIcon,
|
||||
HistoryIcon,
|
||||
MoreHorizontalIcon,
|
||||
NetworkIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlayIcon,
|
||||
PowerIcon,
|
||||
PowerOffIcon,
|
||||
|
|
@ -16,6 +20,9 @@ import { compressToEncodedURIComponent } from 'lz-string';
|
|||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { QueryPlanSchema } from '@/lib/query-plan/schema';
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import type {
|
||||
|
|
@ -24,6 +31,7 @@ import type {
|
|||
LaboratoryHistorySubscription,
|
||||
} from '../../lib/history';
|
||||
import type { LaboratoryOperation } from '../../lib/operations';
|
||||
import { QueryPlanTree, renderQueryPlan } from '../../lib/query-plan/utils';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
|
|
@ -38,13 +46,14 @@ import {
|
|||
} from '../ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from '../ui/dropdown-menu';
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
|
||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable';
|
||||
import { ScrollArea, ScrollBar } from '../ui/scroll-area';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Spinner } from '../ui/spinner';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Toggle } from '../ui/toggle';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
|
||||
import { Builder } from './builder';
|
||||
import { useLaboratory } from './context';
|
||||
import { Editor } from './editor';
|
||||
|
|
@ -86,6 +95,7 @@ const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: b
|
|||
<Editor
|
||||
uri={monaco.Uri.file('headers.json')}
|
||||
value={operation?.headers ?? ''}
|
||||
language="json"
|
||||
onChange={value => {
|
||||
updateActiveOperation({
|
||||
headers: value ?? '',
|
||||
|
|
@ -109,6 +119,7 @@ const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly?
|
|||
<Editor
|
||||
uri={monaco.Uri.file('extensions.json')}
|
||||
value={operation?.extensions ?? ''}
|
||||
language="json"
|
||||
onChange={value => {
|
||||
updateActiveOperation({
|
||||
extensions: value ?? '',
|
||||
|
|
@ -178,6 +189,56 @@ export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHis
|
|||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
export const ResponseQueryPlan = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
|
||||
const [mode, setMode] = useState<'text' | 'visual'>('text');
|
||||
|
||||
const queryPlan = useMemo(() => {
|
||||
try {
|
||||
const queryPlan =
|
||||
JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}').extensions
|
||||
?.queryPlan ?? {};
|
||||
|
||||
if (!queryPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return QueryPlanSchema.safeParse(queryPlan).success ? queryPlan : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [historyItem]);
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<ToggleGroup
|
||||
className="bg-card absolute right-4 top-4 z-10 shadow-sm"
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={mode}
|
||||
onValueChange={value => setMode(value as 'text' | 'visual')}
|
||||
>
|
||||
<ToggleGroupItem value="text">
|
||||
<AlignLeftIcon className="size-4" />
|
||||
Text
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="visual">
|
||||
<NetworkIcon className="size-4" />
|
||||
Visual
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{mode === 'visual' ? (
|
||||
<QueryPlanTree key={historyItem?.id} plan={queryPlan} />
|
||||
) : (
|
||||
<Editor
|
||||
value={renderQueryPlan(queryPlan)}
|
||||
defaultLanguage="graphql"
|
||||
theme="hive-laboratory"
|
||||
options={{ readOnly: true }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResponseSubscription = ({
|
||||
historyItem,
|
||||
|
|
@ -245,6 +306,8 @@ export const ResponseSubscription = ({
|
|||
};
|
||||
|
||||
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
const isError = useMemo(() => {
|
||||
if (!historyItem) {
|
||||
return false;
|
||||
|
|
@ -261,12 +324,65 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
|||
);
|
||||
}, [historyItem]);
|
||||
|
||||
const hasValidQueryPlan = useMemo(() => {
|
||||
if (!historyItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queryPlan = JSON.parse(historyItem.response).extensions?.queryPlan;
|
||||
|
||||
if (!queryPlan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return QueryPlanSchema.safeParse(queryPlan).success;
|
||||
}, [historyItem?.response]);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="response" className="grid size-full grid-rows-[auto_1fr]">
|
||||
<TabsList className="h-[49.5px] w-full justify-start rounded-none border-b bg-transparent p-3">
|
||||
<Tabs
|
||||
defaultValue="response"
|
||||
className={cn('bg-card grid size-full grid-rows-[auto_1fr]', {
|
||||
'z-100 absolute inset-0 size-full': isFullScreen,
|
||||
})}
|
||||
>
|
||||
<TabsList className="h-[50px] w-full items-center justify-start rounded-none border-b bg-transparent p-3">
|
||||
{isFullScreen ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-2 mt-0.5 h-6 w-6"
|
||||
onClick={() => setIsFullScreen(false)}
|
||||
>
|
||||
<PanelLeftOpenIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Minimize panel</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mr-2 mt-0.5 h-6 w-6"
|
||||
onClick={() => setIsFullScreen(true)}
|
||||
>
|
||||
<PanelLeftCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Maximize panel</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<TabsTrigger value="response" className="grow-0 rounded-sm">
|
||||
Response
|
||||
</TabsTrigger>
|
||||
{hasValidQueryPlan && (
|
||||
<TabsTrigger value="query-plan" className="grow-0 rounded-sm">
|
||||
Query Plan
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="headers" className="grow-0 rounded-sm">
|
||||
Headers
|
||||
</TabsTrigger>
|
||||
|
|
@ -277,7 +393,7 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
|||
)}
|
||||
{historyItem ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{historyItem?.status && (
|
||||
{!!historyItem?.status && (
|
||||
<Badge
|
||||
className={cn('bg-green-400/10 text-green-500', {
|
||||
'bg-red-400/10 text-red-500': isError,
|
||||
|
|
@ -315,6 +431,9 @@ export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryReque
|
|||
<TabsContent value="response" className="overflow-hidden">
|
||||
<ResponseBody historyItem={historyItem} />
|
||||
</TabsContent>
|
||||
<TabsContent value="query-plan" className="overflow-hidden">
|
||||
<ResponseQueryPlan historyItem={historyItem} />
|
||||
</TabsContent>
|
||||
<TabsContent value="headers" className="overflow-hidden">
|
||||
<ResponseHeaders historyItem={historyItem} />
|
||||
</TabsContent>
|
||||
|
|
@ -332,6 +451,7 @@ const saveToCollectionFormSchema = z.object({
|
|||
export const Query = (props: {
|
||||
onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void;
|
||||
operation?: LaboratoryOperation | null;
|
||||
onOperationNameChange?: (operationName: string | null) => void;
|
||||
isReadOnly?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
|
|
@ -342,6 +462,7 @@ export const Query = (props: {
|
|||
updateActiveOperation,
|
||||
collections,
|
||||
addOperationToCollection,
|
||||
addCollection,
|
||||
addHistory,
|
||||
stopActiveOperation,
|
||||
addResponseToHistory,
|
||||
|
|
@ -358,6 +479,8 @@ export const Query = (props: {
|
|||
setPluginsState,
|
||||
} = useLaboratory();
|
||||
|
||||
const [operationName, setOperationName] = useState<string | null>(null);
|
||||
|
||||
const operation = useMemo(() => {
|
||||
return props.operation ?? activeOperation ?? null;
|
||||
}, [props.operation, activeOperation]);
|
||||
|
|
@ -401,6 +524,7 @@ export const Query = (props: {
|
|||
void runActiveOperation(endpoint, {
|
||||
env: result?.env,
|
||||
headers: result?.headers,
|
||||
operationName: operationName ?? undefined,
|
||||
onResponse: data => {
|
||||
addResponseToHistory(newItemHistory.id, data);
|
||||
},
|
||||
|
|
@ -413,22 +537,38 @@ export const Query = (props: {
|
|||
const response = await runActiveOperation(endpoint, {
|
||||
env: result?.env,
|
||||
headers: result?.headers,
|
||||
operationName: operationName ?? undefined,
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = response.status;
|
||||
const extensionsResponse = (response.extensions?.response as {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}) ?? {
|
||||
status: 0,
|
||||
headers: {},
|
||||
};
|
||||
|
||||
delete response.extensions?.request;
|
||||
delete response.extensions?.response;
|
||||
|
||||
if (Object.keys(response.extensions ?? {}).length === 0) {
|
||||
delete response.extensions;
|
||||
}
|
||||
|
||||
const status = extensionsResponse.status;
|
||||
const duration = performance.now() - startTime;
|
||||
const responseText = await response.text();
|
||||
const responseText = JSON.stringify(response, null, 2);
|
||||
const size = responseText.length;
|
||||
|
||||
const newItemHistory = addHistory({
|
||||
status,
|
||||
duration,
|
||||
size,
|
||||
headers: JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2),
|
||||
headers: JSON.stringify(extensionsResponse.headers, null, 2),
|
||||
operation,
|
||||
preflightLogs: result?.logs ?? [],
|
||||
response: responseText,
|
||||
|
|
@ -439,6 +579,7 @@ export const Query = (props: {
|
|||
}
|
||||
}, [
|
||||
operation,
|
||||
operationName,
|
||||
endpoint,
|
||||
isActiveOperationSubscription,
|
||||
addHistory,
|
||||
|
|
@ -477,15 +618,34 @@ export const Query = (props: {
|
|||
return;
|
||||
}
|
||||
|
||||
addOperationToCollection(value.collectionId, {
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
});
|
||||
const collection = collections.find(c => c.id === value.collectionId);
|
||||
|
||||
if (!collection) {
|
||||
addCollection({
|
||||
name: value.collectionId,
|
||||
operations: [
|
||||
{
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
addOperationToCollection(value.collectionId, {
|
||||
id: operation.id ?? '',
|
||||
name: operation.name ?? '',
|
||||
query: operation.query ?? '',
|
||||
variables: operation.variables ?? '',
|
||||
headers: operation.headers ?? '',
|
||||
extensions: operation.extensions ?? '',
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSaveToCollectionDialogOpen(false);
|
||||
},
|
||||
|
|
@ -529,10 +689,8 @@ export const Query = (props: {
|
|||
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new collection of operations to your laboratory.
|
||||
</DialogDescription>
|
||||
<DialogTitle>Save operation to collection</DialogTitle>
|
||||
<DialogDescription>Save the current operation to a collection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<form
|
||||
|
|
@ -543,33 +701,58 @@ export const Query = (props: {
|
|||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
{collections.length > 0 ? (
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select collection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select collection" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collections.map(c => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
) : (
|
||||
<saveToCollectionForm.Field name="collectionId">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>New collection name</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Enter name of the collection"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</saveToCollectionForm.Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -700,6 +883,10 @@ export const Query = (props: {
|
|||
query: value ?? '',
|
||||
});
|
||||
}}
|
||||
onOperationNameChange={operationName => {
|
||||
setOperationName(operationName);
|
||||
props.onOperationNameChange?.(operationName);
|
||||
}}
|
||||
language="graphql"
|
||||
theme="hive-laboratory"
|
||||
options={{
|
||||
|
|
@ -715,6 +902,7 @@ export const Operation = (props: {
|
|||
historyItem?: LaboratoryHistory;
|
||||
}) => {
|
||||
const { activeOperation, history } = useLaboratory();
|
||||
const [operationName, setOperationName] = useState<string | null>(null);
|
||||
|
||||
const operation = useMemo(() => {
|
||||
return props.operation ?? activeOperation ?? null;
|
||||
|
|
@ -735,16 +923,20 @@ export const Operation = (props: {
|
|||
}, [props.historyItem]);
|
||||
|
||||
return (
|
||||
<div className="bg-card size-full">
|
||||
<div className="bg-card relative size-full">
|
||||
<ResizablePanelGroup direction="horizontal" className="size-full">
|
||||
<ResizablePanel defaultSize={25}>
|
||||
<Builder operation={operation} isReadOnly={isReadOnly} />
|
||||
<Builder operation={operation} operationName={operationName} isReadOnly={isReadOnly} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={10} defaultSize={40}>
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={70}>
|
||||
<Query operation={operation} isReadOnly={isReadOnly} />
|
||||
<Query
|
||||
operation={operation}
|
||||
isReadOnly={isReadOnly}
|
||||
onOperationNameChange={setOperationName}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel minSize={10} defaultSize={30}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Field, FieldGroup, FieldLabel } from '../ui/field';
|
||||
|
|
@ -8,6 +10,16 @@ import { useLaboratory } from './context';
|
|||
const settingsFormSchema = z.object({
|
||||
fetch: z.object({
|
||||
credentials: z.enum(['include', 'omit', 'same-origin']),
|
||||
timeout: z.number().optional(),
|
||||
retry: z.number().optional(),
|
||||
useGETForQueries: z.boolean().optional(),
|
||||
}),
|
||||
subscriptions: z.object({
|
||||
protocol: z.enum(['SSE', 'GRAPHQL_SSE', 'WS', 'LEGACY_WS']),
|
||||
}),
|
||||
introspection: z.object({
|
||||
method: z.enum(['GET', 'POST']).optional(),
|
||||
schemaDescription: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -25,12 +37,12 @@ export const Settings = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card size-full p-3">
|
||||
<div className="bg-card size-full overflow-y-auto p-3">
|
||||
<form
|
||||
id="settings-form"
|
||||
onSubmit={form.handleSubmit}
|
||||
onChange={form.handleSubmit}
|
||||
className="mx-auto max-w-2xl"
|
||||
className="mx-auto flex max-w-2xl flex-col gap-4"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -66,6 +78,148 @@ export const Settings = () => {
|
|||
);
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="fetch.timeout">
|
||||
{field => {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={field.name}>Timeout</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
name={field.name}
|
||||
value={field.state.value ?? ''}
|
||||
onChange={e =>
|
||||
field.handleChange(
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="fetch.retry">
|
||||
{field => {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={field.name}>Retry</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
name={field.name}
|
||||
value={field.state.value ?? ''}
|
||||
onChange={e =>
|
||||
field.handleChange(
|
||||
e.target.value === '' ? undefined : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="fetch.useGETForQueries">
|
||||
{field => {
|
||||
return (
|
||||
<Field className="flex-row items-center">
|
||||
<Switch
|
||||
className="!w-8"
|
||||
checked={field.state.value ?? false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
<FieldLabel htmlFor={field.name}>Use GET for queries</FieldLabel>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Subscriptions</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the subscriptions options for the laboratory.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<form.Field name="subscriptions.protocol">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Protocol</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={value =>
|
||||
field.handleChange(value as 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select protocol" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SSE">SSE</SelectItem>
|
||||
<SelectItem value="GRAPHQL_SSE">GRAPHQL_SSE</SelectItem>
|
||||
<SelectItem value="WS">WS</SelectItem>
|
||||
<SelectItem value="LEGACY_WS">LEGACY_WS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Introspection</CardTitle>
|
||||
<CardDescription>
|
||||
Configure the introspection options for the laboratory.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<form.Field name="introspection.method">
|
||||
{field => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={field.name}>Method</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={value => field.handleChange(value as 'GET' | 'POST')}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
<form.Field name="introspection.schemaDescription">
|
||||
{field => {
|
||||
return (
|
||||
<Field className="flex-row items-center">
|
||||
<Switch
|
||||
className="!w-8"
|
||||
checked={field.state.value ?? false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
<FieldLabel htmlFor={field.name}>Schema description</FieldLabel>
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
</form.Field>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { useLaboratory } from '../laboratory/context';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
|
|
@ -13,7 +14,11 @@ function AlertDialogTrigger({
|
|||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} container={container} />
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60',
|
||||
'!text-white !hover:bg-destructive/90 !focus-visible:ring-destructive/40 !bg-destructive/60',
|
||||
outline:
|
||||
'border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { useLaboratory } from '@/components/laboratory/context';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
|
|
@ -12,14 +12,9 @@ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive
|
|||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />
|
||||
<div ref={setContainer} style={{ display: 'contents' }} />
|
||||
</>
|
||||
);
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} container={container} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
|
|
@ -34,7 +29,7 @@ function DialogOverlay({
|
|||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 z-150 fixed inset-0 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -56,7 +51,7 @@ function DialogContent({
|
|||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 z-150 fixed left-[50%] top-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-200 relative max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
|
|
|
|||
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal file
55
packages/libraries/laboratory/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react';
|
||||
import { Slider as SliderPrimitive } from 'radix-ui';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-1.5',
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 focus-visible:outline-hidden block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import * as React from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { toggleVariants } from './toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
|
|
@ -3,7 +3,7 @@ import * as TogglePrimitive from '@radix-ui/react-toggle';
|
|||
import { cn } from '../../lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useLaboratory } from '@/components/laboratory/context';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ function TooltipContent({
|
|||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
const { container } = useLaboratory();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -42,7 +42,7 @@ function TooltipContent({
|
|||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-150 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -51,7 +51,6 @@ function TooltipContent({
|
|||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
<div ref={setContainer} style={{ display: 'contents' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.hive-laboratory .inactive-line {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hive-laboratory {
|
||||
--color-neutral-1: 0 0% 99%;
|
||||
--color-neutral-2: 180 9% 97%;
|
||||
|
|
@ -57,14 +61,14 @@
|
|||
--color-neutral-12: 175 23% 10%;
|
||||
|
||||
--color-accent: 206 96% 35%;
|
||||
|
||||
--color-ring: 216 58% 49%;
|
||||
--color-destructive: 357 96% 58%;
|
||||
|
||||
--radius: var(--hive-laboratory-radius, 0.5rem);
|
||||
--background: var(--hive-laboratory-background, var(--color-neutral-2));
|
||||
--foreground: var(--hive-laboratory-foreground, var(--color-neutral-11));
|
||||
|
||||
--muted: var(--hive-laboratory-muted, 24 9.8% 10%);
|
||||
--muted: var(--hive-laboratory-muted, var(--color-neutral-3));
|
||||
--muted-foreground: var(--hive-laboratory-muted-foreground, var(--color-neutral-11));
|
||||
|
||||
--popover: var(--hive-laboratory-popover, var(--color-neutral-3));
|
||||
|
|
@ -80,12 +84,12 @@
|
|||
--primary-foreground: var(--hive-laboratory-primary-foreground, var(--color-neutral-1));
|
||||
|
||||
--secondary: var(--hive-laboratory-secondary, var(--color-neutral-3));
|
||||
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-11));
|
||||
--secondary-foreground: var(--hive-laboratory-secondary-foreground, var(--color-neutral-8));
|
||||
|
||||
--accent: var(--hive-laboratory-accent, var(--color-neutral-4));
|
||||
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
||||
|
||||
--destructive: var(--hive-laboratory-destructive, var(--red-500));
|
||||
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
|
||||
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
||||
|
||||
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
||||
|
|
@ -106,6 +110,7 @@
|
|||
--color-neutral-12: 204 14% 93%;
|
||||
|
||||
--color-accent: 48 100% 83%;
|
||||
--color-destructive: 358.75 100% 70%;
|
||||
|
||||
--radius: var(--hive-laboratory-radius, 0.5rem);
|
||||
--background: var(--hive-laboratory-background, var(--color-neutral-1));
|
||||
|
|
@ -132,7 +137,7 @@
|
|||
--accent: var(--hive-laboratory-accent, var(--color-neutral-6));
|
||||
--accent-foreground: var(--hive-laboratory-accent-foreground, var(--color-neutral-11));
|
||||
|
||||
--destructive: var(--hive-laboratory-destructive, var(--red-500));
|
||||
--destructive: var(--hive-laboratory-destructive, var(--color-destructive));
|
||||
--destructive-foreground: var(--hive-laboratory-destructive-foreground, var(--color-neutral-1));
|
||||
|
||||
--ring: var(--hive-laboratory-ring, var(--color-ring));
|
||||
|
|
@ -159,3 +164,9 @@
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import ReactDOM from 'react-dom/client';
|
||||
import { Laboratory } from './components/laboratory/laboratory';
|
||||
import { Laboratory, LaboratoryProps } from './components/laboratory/laboratory';
|
||||
|
||||
export * from './components/laboratory/laboratory';
|
||||
export * from './components/laboratory/context';
|
||||
|
|
@ -17,7 +17,7 @@ export * from './lib/tabs';
|
|||
export * from './lib/tests';
|
||||
export * from './lib/plugins';
|
||||
|
||||
export const renderLaboratory = (el: HTMLElement) => {
|
||||
export const renderLaboratory = (el: HTMLElement, props: LaboratoryProps) => {
|
||||
const prefix = 'hive-laboratory';
|
||||
|
||||
const getLocalStorage = (key: string) => {
|
||||
|
|
@ -74,6 +74,7 @@ export const renderLaboratory = (el: HTMLElement) => {
|
|||
onHistoryChange={history => {
|
||||
setLocalStorage('history', history);
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Toggle as TogglePrimitive } from 'radix-ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 min-w-9 px-2',
|
||||
sm: 'h-8 min-w-8 px-1.5',
|
||||
lg: 'h-10 min-w-10 px-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
|
|
@ -20,8 +20,10 @@ export interface LaboratoryCollection {
|
|||
|
||||
export interface LaboratoryCollectionsActions {
|
||||
addCollection: (
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => void;
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||
},
|
||||
) => LaboratoryCollection;
|
||||
addOperationToCollection: (
|
||||
collectionId: string,
|
||||
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
|
||||
|
|
@ -30,7 +32,7 @@ export interface LaboratoryCollectionsActions {
|
|||
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
|
||||
updateCollection: (
|
||||
collectionId: string,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => void;
|
||||
updateOperationInCollection: (
|
||||
collectionId: string,
|
||||
|
|
@ -73,17 +75,27 @@ export const useCollections = (
|
|||
);
|
||||
|
||||
const addCollection = useCallback(
|
||||
(collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>) => {
|
||||
(
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'> & {
|
||||
operations?: Omit<LaboratoryCollectionOperation, 'createdAt'>[];
|
||||
},
|
||||
) => {
|
||||
const newCollection: LaboratoryCollection = {
|
||||
...collection,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date().toISOString(),
|
||||
operations: [],
|
||||
operations:
|
||||
collection.operations?.map(operation => ({
|
||||
...operation,
|
||||
createdAt: new Date().toISOString(),
|
||||
})) ?? [],
|
||||
};
|
||||
const newCollections = [...collections, newCollection];
|
||||
setCollections(newCollections);
|
||||
props.onCollectionsChange?.(newCollections);
|
||||
props.onCollectionCreate?.(newCollection);
|
||||
|
||||
return newCollection;
|
||||
},
|
||||
[collections, props],
|
||||
);
|
||||
|
|
@ -94,6 +106,7 @@ export const useCollections = (
|
|||
...operation,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const newCollections = collections.map(collection =>
|
||||
collection.id === collectionId
|
||||
? {
|
||||
|
|
@ -105,7 +118,9 @@ export const useCollections = (
|
|||
|
||||
setCollections(newCollections);
|
||||
props.onCollectionsChange?.(newCollections);
|
||||
|
||||
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
|
||||
|
||||
if (updatedCollection) {
|
||||
props.onCollectionUpdate?.(updatedCollection);
|
||||
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
|
||||
|
|
@ -158,7 +173,10 @@ export const useCollections = (
|
|||
);
|
||||
|
||||
const updateCollection = useCallback(
|
||||
(collectionId: string, collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>) => {
|
||||
(
|
||||
collectionId: string,
|
||||
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
|
||||
) => {
|
||||
const newCollections = collections.map(c =>
|
||||
c.id === collectionId ? { ...c, ...collection } : c,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
buildClientSchema,
|
||||
getIntrospectionQuery,
|
||||
GraphQLSchema,
|
||||
introspectionFromSchema,
|
||||
type IntrospectionQuery,
|
||||
} from 'graphql';
|
||||
import { toast } from 'sonner';
|
||||
// import z from 'zod';
|
||||
import { asyncInterval } from '@/lib/utils';
|
||||
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
|
||||
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
|
||||
|
||||
export interface LaboratoryEndpointState {
|
||||
endpoint: string | null;
|
||||
|
|
@ -24,6 +28,7 @@ export const useEndpoint = (props: {
|
|||
defaultEndpoint?: string | null;
|
||||
onEndpointChange?: (endpoint: string | null) => void;
|
||||
defaultSchemaIntrospection?: IntrospectionQuery | null;
|
||||
settingsApi?: LaboratorySettingsState & LaboratorySettingsActions;
|
||||
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
|
||||
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
|
||||
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
|
||||
|
|
@ -40,35 +45,104 @@ export const useEndpoint = (props: {
|
|||
return introspection ? buildClientSchema(introspection) : null;
|
||||
}, [introspection]);
|
||||
|
||||
const fetchSchema = useCallback(async () => {
|
||||
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
|
||||
setIntrospection(props.defaultSchemaIntrospection);
|
||||
const loader = useMemo(() => new UrlLoader(), []);
|
||||
|
||||
const fetchSchema = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
if (endpoint === props.defaultEndpoint && props.defaultSchemaIntrospection) {
|
||||
setIntrospection(props.defaultSchemaIntrospection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
setIntrospection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loader.load(endpoint, {
|
||||
subscriptionsEndpoint: endpoint,
|
||||
subscriptionsProtocol:
|
||||
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
|
||||
SubscriptionProtocol.GRAPHQL_SSE,
|
||||
credentials: props.settingsApi?.settings.fetch.credentials,
|
||||
specifiedByUrl: true,
|
||||
directiveIsRepeatable: true,
|
||||
inputValueDeprecation: true,
|
||||
retry: props.settingsApi?.settings.fetch.retry,
|
||||
timeout: props.settingsApi?.settings.fetch.timeout,
|
||||
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
|
||||
exposeHTTPDetailsInExtensions: true,
|
||||
descriptions: props.settingsApi?.settings.introspection.schemaDescription ?? false,
|
||||
method: props.settingsApi?.settings.introspection.method ?? 'POST',
|
||||
fetch: (input: string | URL | Request, init?: RequestInit) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('Failed to fetch schema');
|
||||
}
|
||||
|
||||
if (!result[0].schema) {
|
||||
throw new Error('Failed to fetch schema');
|
||||
}
|
||||
|
||||
setIntrospection(introspectionFromSchema(result[0].schema));
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.error('Failed to fetch schema');
|
||||
}
|
||||
|
||||
setIntrospection(null);
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[
|
||||
endpoint,
|
||||
props.settingsApi?.settings.fetch.timeout,
|
||||
props.settingsApi?.settings.introspection.method,
|
||||
props.settingsApi?.settings.introspection.schemaDescription,
|
||||
],
|
||||
);
|
||||
|
||||
const shouldPollSchema = useMemo(() => {
|
||||
return endpoint !== props.defaultEndpoint || !props.defaultSchemaIntrospection;
|
||||
}, [endpoint, props.defaultEndpoint, props.defaultSchemaIntrospection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldPollSchema || !endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!endpoint) {
|
||||
setIntrospection(null);
|
||||
return;
|
||||
}
|
||||
const intervalController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
query: getIntrospectionQuery(),
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(r => r.json());
|
||||
void asyncInterval(
|
||||
async () => {
|
||||
try {
|
||||
await fetchSchema(intervalController.signal);
|
||||
} catch {
|
||||
intervalController.abort();
|
||||
}
|
||||
},
|
||||
5000,
|
||||
intervalController.signal,
|
||||
);
|
||||
|
||||
setIntrospection(response.data as IntrospectionQuery);
|
||||
} catch {
|
||||
toast.error('Failed to fetch schema');
|
||||
setIntrospection(null);
|
||||
return;
|
||||
}
|
||||
}, [endpoint]);
|
||||
return () => {
|
||||
intervalController.abort();
|
||||
};
|
||||
}, [shouldPollSchema, fetchSchema]);
|
||||
|
||||
const restoreDefaultEndpoint = useCallback(() => {
|
||||
if (props.defaultEndpoint) {
|
||||
|
|
@ -77,10 +151,10 @@ export const useEndpoint = (props: {
|
|||
}, [props.defaultEndpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint) {
|
||||
if (endpoint && !shouldPollSchema) {
|
||||
void fetchSchema();
|
||||
}
|
||||
}, [endpoint, fetchSchema]);
|
||||
}, [endpoint, fetchSchema, shouldPollSchema]);
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { GraphQLSchema } from 'graphql';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import {
|
||||
DocumentNode,
|
||||
ExecutionResult,
|
||||
getOperationAST,
|
||||
GraphQLError,
|
||||
Kind,
|
||||
parse,
|
||||
type GraphQLSchema,
|
||||
} from 'graphql';
|
||||
import { decompressFromEncodedURIComponent } from 'lz-string';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { isAsyncIterable } from '@/lib/utils';
|
||||
import { SubscriptionProtocol, UrlLoader } from '@graphql-tools/url-loader';
|
||||
import { LaboratoryPermission, LaboratoryPermissions } from '../components/laboratory/context';
|
||||
import type {
|
||||
LaboratoryCollectionOperation,
|
||||
|
|
@ -23,6 +32,14 @@ import type { LaboratoryPreflightActions, LaboratoryPreflightState } from './pre
|
|||
import type { LaboratorySettingsActions, LaboratorySettingsState } from './settings';
|
||||
import type { LaboratoryTabOperation, LaboratoryTabsActions, LaboratoryTabsState } from './tabs';
|
||||
|
||||
function getOperationType(query: string): 'query' | 'mutation' | 'subscription' | null {
|
||||
try {
|
||||
return getOperationAST(parse(query))?.operation ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LaboratoryOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -45,18 +62,28 @@ export interface LaboratoryOperationsActions {
|
|||
setOperations: (operations: LaboratoryOperation[]) => void;
|
||||
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => void;
|
||||
deleteOperation: (operationId: string) => void;
|
||||
addPathToActiveOperation: (path: string) => void;
|
||||
deletePathFromActiveOperation: (path: string) => void;
|
||||
addArgToActiveOperation: (path: string, argName: string, schema: GraphQLSchema) => void;
|
||||
deleteArgFromActiveOperation: (path: string, argName: string) => void;
|
||||
addPathToActiveOperation: (path: string, operationName?: string | null) => void;
|
||||
deletePathFromActiveOperation: (path: string, operationName?: string | null) => void;
|
||||
addArgToActiveOperation: (
|
||||
path: string,
|
||||
argName: string,
|
||||
schema: GraphQLSchema,
|
||||
operationName?: string | null,
|
||||
) => void;
|
||||
deleteArgFromActiveOperation: (
|
||||
path: string,
|
||||
argName: string,
|
||||
operationName?: string | null,
|
||||
) => void;
|
||||
runActiveOperation: (
|
||||
endpoint: string,
|
||||
options?: {
|
||||
env?: LaboratoryEnv;
|
||||
headers?: Record<string, string>;
|
||||
operationName?: string;
|
||||
onResponse?: (response: string) => void;
|
||||
},
|
||||
) => Promise<Response | null>;
|
||||
) => Promise<ExecutionResult | null>;
|
||||
stopActiveOperation: (() => void) | null;
|
||||
isActiveOperationLoading: boolean;
|
||||
isOperationLoading: (operationId: string) => boolean;
|
||||
|
|
@ -70,6 +97,27 @@ export interface LaboratoryOperationsCallbacks {
|
|||
onOperationDelete?: (operation: LaboratoryOperation) => void;
|
||||
}
|
||||
|
||||
const getOperationWithFragments = (
|
||||
document: DocumentNode,
|
||||
operationName?: string,
|
||||
): DocumentNode => {
|
||||
const definitions = document.definitions.filter(definition => {
|
||||
if (
|
||||
definition.kind === Kind.OPERATION_DEFINITION &&
|
||||
operationName &&
|
||||
definition.name?.value !== operationName
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
kind: Kind.DOCUMENT,
|
||||
definitions,
|
||||
};
|
||||
};
|
||||
|
||||
export const useOperations = (
|
||||
props: {
|
||||
checkPermissions: (
|
||||
|
|
@ -243,13 +291,13 @@ export const useOperations = (
|
|||
);
|
||||
|
||||
const addPathToActiveOperation = useCallback(
|
||||
(path: string) => {
|
||||
(path: string, operationName?: string | null) => {
|
||||
if (!activeOperation) {
|
||||
return;
|
||||
}
|
||||
const newActiveOperation = {
|
||||
...activeOperation,
|
||||
query: addPathToQuery(activeOperation.query, path),
|
||||
query: addPathToQuery(activeOperation.query, path, operationName),
|
||||
};
|
||||
updateActiveOperation(newActiveOperation);
|
||||
},
|
||||
|
|
@ -257,14 +305,14 @@ export const useOperations = (
|
|||
);
|
||||
|
||||
const deletePathFromActiveOperation = useCallback(
|
||||
(path: string) => {
|
||||
(path: string, operationName?: string | null) => {
|
||||
if (!activeOperation?.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newActiveOperation = {
|
||||
...activeOperation,
|
||||
query: deletePathFromQuery(activeOperation.query, path),
|
||||
query: deletePathFromQuery(activeOperation.query, path, operationName),
|
||||
};
|
||||
updateActiveOperation(newActiveOperation);
|
||||
},
|
||||
|
|
@ -272,14 +320,14 @@ export const useOperations = (
|
|||
);
|
||||
|
||||
const addArgToActiveOperation = useCallback(
|
||||
(path: string, argName: string, schema: GraphQLSchema) => {
|
||||
(path: string, argName: string, schema: GraphQLSchema, operationName?: string | null) => {
|
||||
if (!activeOperation?.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newActiveOperation = {
|
||||
...activeOperation,
|
||||
query: addArgToField(activeOperation.query, path, argName, schema),
|
||||
query: addArgToField(activeOperation.query, path, argName, schema, operationName),
|
||||
};
|
||||
updateActiveOperation(newActiveOperation);
|
||||
},
|
||||
|
|
@ -287,14 +335,14 @@ export const useOperations = (
|
|||
);
|
||||
|
||||
const deleteArgFromActiveOperation = useCallback(
|
||||
(path: string, argName: string) => {
|
||||
(path: string, argName: string, operationName?: string | null) => {
|
||||
if (!activeOperation?.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newActiveOperation = {
|
||||
...activeOperation,
|
||||
query: removeArgFromField(activeOperation.query, path, argName),
|
||||
query: removeArgFromField(activeOperation.query, path, argName, operationName),
|
||||
};
|
||||
updateActiveOperation(newActiveOperation);
|
||||
},
|
||||
|
|
@ -316,6 +364,8 @@ export const useOperations = (
|
|||
return activeOperation ? isOperationLoading(activeOperation.id) : false;
|
||||
}, [activeOperation, isOperationLoading]);
|
||||
|
||||
const loader = useMemo(() => new UrlLoader(), []);
|
||||
|
||||
const runActiveOperation = useCallback(
|
||||
async (
|
||||
endpoint: string,
|
||||
|
|
@ -323,10 +373,11 @@ export const useOperations = (
|
|||
env?: LaboratoryEnv;
|
||||
headers?: Record<string, string>;
|
||||
onResponse?: (response: string) => void;
|
||||
operationName?: string;
|
||||
},
|
||||
plugins: LaboratoryPlugin[] = props.pluginsApi?.plugins ?? [],
|
||||
pluginsState: Record<string, any> = props.pluginsApi?.pluginsState ?? {},
|
||||
) => {
|
||||
): Promise<ExecutionResult | null> => {
|
||||
if (!activeOperation?.query) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -382,100 +433,91 @@ export const useOperations = (
|
|||
)
|
||||
: {};
|
||||
|
||||
if (activeOperation.query.startsWith('subscription')) {
|
||||
const client = createClient({
|
||||
url: endpoint.replace('http', 'ws'),
|
||||
connectionParams: {
|
||||
...mergedHeaders,
|
||||
const executor = loader.getExecutorAsync(endpoint, {
|
||||
subscriptionsEndpoint: endpoint,
|
||||
subscriptionsProtocol:
|
||||
(props.settingsApi?.settings.subscriptions.protocol as SubscriptionProtocol) ??
|
||||
SubscriptionProtocol.GRAPHQL_SSE,
|
||||
credentials: props.settingsApi?.settings.fetch.credentials,
|
||||
specifiedByUrl: true,
|
||||
directiveIsRepeatable: true,
|
||||
inputValueDeprecation: true,
|
||||
retry: props.settingsApi?.settings.fetch.retry,
|
||||
timeout: props.settingsApi?.settings.fetch.timeout,
|
||||
useGETForQueries: props.settingsApi?.settings.fetch.useGETForQueries,
|
||||
exposeHTTPDetailsInExtensions: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
const document = getOperationWithFragments(parse(activeOperation.query));
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
setStopOperationsFunctions(prev => ({
|
||||
...prev,
|
||||
[activeOperation.id]: () => {
|
||||
abortController.abort();
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await executor({
|
||||
document,
|
||||
operationName: options?.operationName,
|
||||
variables,
|
||||
extensions: {
|
||||
...extensions,
|
||||
headers: mergedHeaders,
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
client.on('connected', () => {
|
||||
console.log('connected');
|
||||
});
|
||||
|
||||
client.on('error', () => {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
});
|
||||
|
||||
client.on('closed', () => {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
});
|
||||
|
||||
client.subscribe(
|
||||
{
|
||||
query: activeOperation.query,
|
||||
variables,
|
||||
extensions,
|
||||
},
|
||||
{
|
||||
next: message => {
|
||||
options?.onResponse?.(JSON.stringify(message ?? {}));
|
||||
},
|
||||
error: () => {},
|
||||
complete: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
setStopOperationsFunctions(prev => ({
|
||||
...prev,
|
||||
[activeOperation.id]: () => {
|
||||
void client.dispose();
|
||||
if (isAsyncIterable(response)) {
|
||||
try {
|
||||
for await (const item of response) {
|
||||
options?.onResponse?.(JSON.stringify(item ?? {}));
|
||||
}
|
||||
} finally {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
if (response.extensions?.response?.body) {
|
||||
delete response.extensions.response.body;
|
||||
}
|
||||
|
||||
const response = fetch(endpoint, {
|
||||
method: 'POST',
|
||||
credentials: props.settingsApi?.settings.fetch.credentials,
|
||||
body: JSON.stringify({
|
||||
query: activeOperation.query,
|
||||
variables,
|
||||
extensions,
|
||||
}),
|
||||
headers: {
|
||||
...mergedHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
}).finally(() => {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
});
|
||||
|
||||
setStopOperationsFunctions(prev => ({
|
||||
...prev,
|
||||
[activeOperation.id]: () => abortController.abort(),
|
||||
}));
|
||||
return response;
|
||||
} catch (error) {
|
||||
setStopOperationsFunctions(prev => {
|
||||
const newStopOperationsFunctions = { ...prev };
|
||||
delete newStopOperationsFunctions[activeOperation.id];
|
||||
return newStopOperationsFunctions;
|
||||
});
|
||||
|
||||
return response;
|
||||
if (error instanceof Error) {
|
||||
return new GraphQLError(error.message);
|
||||
}
|
||||
|
||||
return new GraphQLError('An unknown error occurred');
|
||||
}
|
||||
},
|
||||
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi],
|
||||
[activeOperation, props.preflightApi, props.envApi, props.pluginsApi, props.settingsApi],
|
||||
);
|
||||
|
||||
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
|
||||
return operation.query?.startsWith('subscription') ?? false;
|
||||
return getOperationType(operation.query) === 'subscription';
|
||||
}, []);
|
||||
|
||||
const isActiveOperationSubscription = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function healQuery(query: string) {
|
|||
return query.replace(/\{(\s+)?\}/g, '');
|
||||
}
|
||||
|
||||
export function isPathInQuery(query: string, path: string, operationName?: string) {
|
||||
export function isPathInQuery(query: string, path: string, operationName?: string | null) {
|
||||
if (!query || !path) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ export function isPathInQuery(query: string, path: string, operationName?: strin
|
|||
return found;
|
||||
}
|
||||
|
||||
export function addPathToQuery(query: string, path: string, operationName?: string) {
|
||||
export function addPathToQuery(query: string, path: string, operationName?: string | null) {
|
||||
query = healQuery(query);
|
||||
|
||||
const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]];
|
||||
|
|
@ -244,7 +244,7 @@ export function addPathToQuery(query: string, path: string, operationName?: stri
|
|||
return print(doc);
|
||||
}
|
||||
|
||||
export function deletePathFromQuery(query: string, path: string, operationName?: string) {
|
||||
export function deletePathFromQuery(query: string, path: string, operationName?: string | null) {
|
||||
query = healQuery(query);
|
||||
|
||||
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
|
||||
|
|
@ -348,8 +348,6 @@ export async function getOperationHash(
|
|||
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
|
||||
) {
|
||||
try {
|
||||
console.log(operation.query, operation.variables);
|
||||
|
||||
const canonicalQuery = print(parse(operation.query));
|
||||
const canonicalVariables = '';
|
||||
const canonical = `${canonicalQuery}\n${canonicalVariables}`;
|
||||
|
|
@ -393,7 +391,12 @@ export function getOperationType(query: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function isArgInQuery(query: string, path: string, argName: string, operationName?: string) {
|
||||
export function isArgInQuery(
|
||||
query: string,
|
||||
path: string,
|
||||
argName: string,
|
||||
operationName?: string | null,
|
||||
) {
|
||||
if (!query || !path) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -527,7 +530,7 @@ export function addArgToField(
|
|||
path: string,
|
||||
argName: string,
|
||||
schema: GraphQLSchema,
|
||||
operationName?: string,
|
||||
operationName?: string | null,
|
||||
) {
|
||||
query = healQuery(query);
|
||||
|
||||
|
|
@ -784,7 +787,7 @@ export function removeArgFromField(
|
|||
query: string,
|
||||
path: string,
|
||||
argName: string,
|
||||
operationName?: string,
|
||||
operationName?: string | null,
|
||||
) {
|
||||
query = healQuery(query);
|
||||
|
||||
|
|
|
|||
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal file
225
packages/libraries/laboratory/src/lib/query-plan/schema.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const FlattenNodePathSegmentSchema = z.union([
|
||||
z.object({ Field: z.string() }),
|
||||
z.object({ TypeCondition: z.array(z.string()) }),
|
||||
z.literal('@'),
|
||||
]);
|
||||
|
||||
export type FlattenNodePathSegment = z.infer<typeof FlattenNodePathSegmentSchema>;
|
||||
|
||||
export const FlattenNodePathSchema = z.array(FlattenNodePathSegmentSchema);
|
||||
export type FlattenNodePath = z.infer<typeof FlattenNodePathSchema>;
|
||||
|
||||
export const FlattenNodePathsSchema = z.array(FlattenNodePathSchema);
|
||||
export type FlattenNodePaths = z.infer<typeof FlattenNodePathsSchema>;
|
||||
|
||||
export const FetchNodePathSegmentSchema = z.union([
|
||||
z.object({ Key: z.string() }),
|
||||
z.object({ TypenameEquals: z.array(z.string()) }),
|
||||
]);
|
||||
export type FetchNodePathSegment = z.infer<typeof FetchNodePathSegmentSchema>;
|
||||
|
||||
export const ValueSetterSchema = z.object({
|
||||
path: z.array(FetchNodePathSegmentSchema),
|
||||
setValueTo: z.string(),
|
||||
});
|
||||
|
||||
export const KeyRenamerSchema = z.object({
|
||||
path: z.array(FetchNodePathSegmentSchema),
|
||||
renameKeyTo: z.string(),
|
||||
});
|
||||
|
||||
export const FetchRewriteSchema = z.union([ValueSetterSchema, KeyRenamerSchema]);
|
||||
export type FetchRewrite = z.infer<typeof FetchRewriteSchema>;
|
||||
|
||||
export const SelectionFieldSchema = z.object({
|
||||
kind: z.literal('Field'),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SelectionInlineFragmentSchema = z.object({
|
||||
kind: z.literal('InlineFragment'),
|
||||
typeCondition: z.string().nullish(),
|
||||
selections: z.array(SelectionFieldSchema).nullish(),
|
||||
});
|
||||
|
||||
export type SelectionInlineFragment = z.infer<typeof SelectionInlineFragmentSchema>;
|
||||
|
||||
export const SelectionFragmentSpreadSchema = z.object({
|
||||
kind: z.literal('FragmentSpread'),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SelectionItemSchema = z.union([
|
||||
SelectionInlineFragmentSchema,
|
||||
SelectionFragmentSpreadSchema,
|
||||
SelectionFieldSchema,
|
||||
]);
|
||||
export type SelectionItem = z.infer<typeof SelectionItemSchema>;
|
||||
|
||||
export const SelectionSetSchema = z.array(SelectionItemSchema);
|
||||
export type SelectionSet = z.infer<typeof SelectionSetSchema>;
|
||||
|
||||
export interface SequenceNodePlan {
|
||||
kind: 'Sequence';
|
||||
nodes: PlanNode[];
|
||||
}
|
||||
export interface ParallelNodePlan {
|
||||
kind: 'Parallel';
|
||||
nodes: PlanNode[];
|
||||
}
|
||||
export interface FlattenNodePlan {
|
||||
kind: 'Flatten';
|
||||
path: FlattenNodePath;
|
||||
node: PlanNode;
|
||||
}
|
||||
export interface ConditionNodePlan {
|
||||
kind: 'Condition';
|
||||
condition: string;
|
||||
ifClause?: PlanNode | null;
|
||||
elseClause?: PlanNode | null;
|
||||
}
|
||||
export interface SubscriptionNodePlan {
|
||||
kind: 'Subscription';
|
||||
primary: PlanNode;
|
||||
}
|
||||
export interface DeferNodePlan {
|
||||
kind: 'Defer';
|
||||
primary: DeferPrimary;
|
||||
deferred: DeferredNode[];
|
||||
}
|
||||
export interface DeferPrimary {
|
||||
subselection?: string | null;
|
||||
node?: PlanNode | null;
|
||||
}
|
||||
export interface DeferredNode {
|
||||
depends: DeferDependency[];
|
||||
label?: string | null;
|
||||
queryPath: string[];
|
||||
subselection?: string | null;
|
||||
node?: PlanNode | null;
|
||||
}
|
||||
|
||||
export type FetchNodePlan = z.infer<typeof FetchNodePlanSchema>;
|
||||
export type BatchFetchNodePlan = z.infer<typeof BatchFetchNodePlanSchema>;
|
||||
|
||||
export type PlanNode =
|
||||
| FetchNodePlan
|
||||
| BatchFetchNodePlan
|
||||
| SequenceNodePlan
|
||||
| ParallelNodePlan
|
||||
| FlattenNodePlan
|
||||
| ConditionNodePlan
|
||||
| SubscriptionNodePlan
|
||||
| DeferNodePlan;
|
||||
|
||||
export const PlanNodeSchema: z.ZodType<PlanNode> = z.lazy(() =>
|
||||
z.discriminatedUnion('kind', [
|
||||
FetchNodePlanSchema,
|
||||
BatchFetchNodePlanSchema,
|
||||
SequenceNodePlanSchema,
|
||||
ParallelNodePlanSchema,
|
||||
FlattenNodePlanSchema,
|
||||
ConditionNodePlanSchema,
|
||||
SubscriptionNodePlanSchema,
|
||||
DeferNodePlanSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
export const FetchNodePlanSchema = z.object({
|
||||
kind: z.literal('Fetch'),
|
||||
serviceName: z.string(),
|
||||
operationKind: z.string().nullish(),
|
||||
operationName: z.string().nullish(),
|
||||
operation: z.string(),
|
||||
variableUsages: z.array(z.string()).nullish(),
|
||||
requires: SelectionSetSchema.nullish(),
|
||||
inputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||
outputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||
});
|
||||
|
||||
export const EntityBatchAliasSchema = z.object({
|
||||
alias: z.string(),
|
||||
representationsVariableName: z.string(),
|
||||
paths: FlattenNodePathsSchema,
|
||||
requires: SelectionSetSchema,
|
||||
inputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||
outputRewrites: z.array(FetchRewriteSchema).nullish(),
|
||||
});
|
||||
export type EntityBatchAlias = z.infer<typeof EntityBatchAliasSchema>;
|
||||
|
||||
export const EntityBatchSchema = z.object({
|
||||
aliases: z.array(EntityBatchAliasSchema),
|
||||
});
|
||||
export type EntityBatch = z.infer<typeof EntityBatchSchema>;
|
||||
|
||||
export const BatchFetchNodePlanSchema = z.object({
|
||||
kind: z.literal('BatchFetch'),
|
||||
serviceName: z.string(),
|
||||
operationKind: z.string().nullish(),
|
||||
operationName: z.string().nullish(),
|
||||
operation: z.string(),
|
||||
variableUsages: z.array(z.string()).nullish(),
|
||||
entityBatch: EntityBatchSchema,
|
||||
});
|
||||
|
||||
export const SequenceNodePlanSchema = z.object({
|
||||
kind: z.literal('Sequence'),
|
||||
nodes: z.array(PlanNodeSchema),
|
||||
});
|
||||
|
||||
export const ParallelNodePlanSchema = z.object({
|
||||
kind: z.literal('Parallel'),
|
||||
nodes: z.array(PlanNodeSchema),
|
||||
});
|
||||
|
||||
export const FlattenNodePlanSchema = z.object({
|
||||
kind: z.literal('Flatten'),
|
||||
path: FlattenNodePathSchema,
|
||||
node: PlanNodeSchema,
|
||||
});
|
||||
|
||||
export const ConditionNodePlanSchema = z.object({
|
||||
kind: z.literal('Condition'),
|
||||
condition: z.string(),
|
||||
ifClause: PlanNodeSchema.nullish(),
|
||||
elseClause: PlanNodeSchema.nullish(),
|
||||
});
|
||||
|
||||
export const SubscriptionNodePlanSchema = z.object({
|
||||
kind: z.literal('Subscription'),
|
||||
primary: PlanNodeSchema,
|
||||
});
|
||||
|
||||
export const DeferDependencySchema = z.object({
|
||||
id: z.string(),
|
||||
deferLabel: z.string().nullish(),
|
||||
});
|
||||
export type DeferDependency = z.infer<typeof DeferDependencySchema>;
|
||||
|
||||
export const DeferPrimarySchema = z.object({
|
||||
subselection: z.string().nullish(),
|
||||
node: PlanNodeSchema.nullish(),
|
||||
});
|
||||
|
||||
export const DeferredNodeSchema = z.object({
|
||||
depends: z.array(DeferDependencySchema),
|
||||
label: z.string().nullish(),
|
||||
queryPath: z.array(z.string()),
|
||||
subselection: z.string().nullish(),
|
||||
node: PlanNodeSchema.nullish(),
|
||||
});
|
||||
|
||||
export const DeferNodePlanSchema = z.object({
|
||||
kind: z.literal('Defer'),
|
||||
primary: DeferPrimarySchema,
|
||||
deferred: z.array(DeferredNodeSchema),
|
||||
});
|
||||
|
||||
export const QueryPlanSchema = z.object({
|
||||
kind: z.literal('QueryPlan'),
|
||||
node: PlanNodeSchema.nullish(),
|
||||
});
|
||||
|
||||
export type QueryPlan = z.infer<typeof QueryPlanSchema>;
|
||||
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal file
795
packages/libraries/laboratory/src/lib/query-plan/utils.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
import { useMemo } from 'react';
|
||||
import { parse, print } from 'graphql';
|
||||
import { isArray } from 'lodash';
|
||||
import {
|
||||
Box,
|
||||
Boxes,
|
||||
ClockIcon,
|
||||
GitForkIcon,
|
||||
Layers2Icon,
|
||||
ListOrderedIcon,
|
||||
LucideProps,
|
||||
NetworkIcon,
|
||||
UnlinkIcon,
|
||||
} from 'lucide-react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Flow, FlowNode } from '@/components/flow';
|
||||
import { GraphQLIcon } from '@/components/icons';
|
||||
import { Editor } from '@/components/laboratory/editor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BatchFetchNodePlan,
|
||||
ConditionNodePlan,
|
||||
DeferNodePlan,
|
||||
FetchNodePlan,
|
||||
FlattenNodePath,
|
||||
FlattenNodePathSegment,
|
||||
FlattenNodePlan,
|
||||
ParallelNodePlan,
|
||||
PlanNode,
|
||||
QueryPlan,
|
||||
SelectionInlineFragment,
|
||||
SelectionSet,
|
||||
SequenceNodePlan,
|
||||
SubscriptionNodePlan,
|
||||
} from './schema';
|
||||
|
||||
function indent(depth: number): string {
|
||||
return ' '.repeat(depth);
|
||||
}
|
||||
|
||||
function normalizeStringSet(value: string[] | Set<string> | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : Array.from(value);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function renderFlattenPathSegment(seg: FlattenNodePathSegment): string {
|
||||
if (seg === '@') return '@';
|
||||
|
||||
if (isObject(seg) && 'Field' in seg) {
|
||||
return String(seg.Field);
|
||||
}
|
||||
|
||||
if (isObject(seg) && 'TypeCondition' in seg) {
|
||||
const names = normalizeStringSet(seg.TypeCondition as string[] | Set<string>);
|
||||
return `|[${names.join('|')}]`;
|
||||
}
|
||||
|
||||
if (isArray(seg)) {
|
||||
return renderFlattenPath(seg);
|
||||
}
|
||||
|
||||
return String(seg);
|
||||
}
|
||||
|
||||
export function renderFlattenPath(path: FlattenNodePath): string {
|
||||
let out = '';
|
||||
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const current = path[i];
|
||||
const next = path[i + 1];
|
||||
out += renderFlattenPathSegment(current);
|
||||
|
||||
if (next !== undefined) {
|
||||
const nextIsTypeCondition = isObject(next) && 'TypeCondition' in next;
|
||||
if (!nextIsTypeCondition) out += '.';
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function renderSelectionSet(
|
||||
selectionSet: SelectionSet | null | undefined,
|
||||
depth = 0,
|
||||
): string {
|
||||
if (!selectionSet?.length) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const item of selectionSet) {
|
||||
if (item.kind === 'InlineFragment') {
|
||||
lines.push(`${indent(depth)}... on ${item.typeCondition} {`);
|
||||
|
||||
for (const property of item.selections ?? []) {
|
||||
lines.push(`${indent(depth + 1)}${property.name}`);
|
||||
}
|
||||
|
||||
lines.push(`${indent(depth)}}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderQueryPlan(plan: QueryPlan): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('QueryPlan {');
|
||||
|
||||
if (plan.node) {
|
||||
lines.push(renderPlanNode(plan.node, 1));
|
||||
} else {
|
||||
lines.push(`${indent(1)}None`);
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderPlanNode(node: PlanNode, depth = 0): string {
|
||||
switch (node.kind) {
|
||||
case 'Fetch':
|
||||
return renderFetchNode(node, depth);
|
||||
|
||||
case 'BatchFetch':
|
||||
return renderBatchFetchNode(node, depth);
|
||||
|
||||
case 'Flatten':
|
||||
return renderFlattenNode(node, depth);
|
||||
|
||||
case 'Sequence':
|
||||
return renderSequenceNode(node, depth);
|
||||
|
||||
case 'Parallel':
|
||||
return renderParallelNode(node, depth);
|
||||
|
||||
case 'Condition':
|
||||
return renderConditionNode(node, depth);
|
||||
|
||||
case 'Subscription':
|
||||
return renderSubscriptionNode(node, depth);
|
||||
|
||||
case 'Defer':
|
||||
return renderDeferNode(node, depth);
|
||||
|
||||
default:
|
||||
return `${indent(depth)}<UnknownNode kind="${(node as { kind?: string }).kind ?? 'unknown'}">`;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderFetchNode(node: FetchNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${indent(depth)}Fetch(service: "${node.serviceName}") {`);
|
||||
|
||||
if (node.requires) {
|
||||
lines.push(`${indent(depth + 1)}{`);
|
||||
const requires = renderSelectionSet(node.requires, depth + 2);
|
||||
if (requires) lines.push(requires);
|
||||
lines.push(`${indent(depth + 1)}} =>`);
|
||||
}
|
||||
|
||||
const slice = node.operation.includes('_entities') ? 2 : 1;
|
||||
|
||||
try {
|
||||
lines.push(
|
||||
`${indent(depth + 1)}{`,
|
||||
renderMultilineBlock(
|
||||
print(parse(node.operation))
|
||||
.split('\n')
|
||||
.slice(slice, slice * -1)
|
||||
.join('\n'),
|
||||
depth + 2 - slice,
|
||||
),
|
||||
`${indent(depth + 1)}}`,
|
||||
);
|
||||
} catch {
|
||||
lines.push(`${indent(depth + 1)}${node.operation}`);
|
||||
}
|
||||
|
||||
lines.push(`${indent(depth)}}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderBatchFetchNode(node: BatchFetchNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${indent(depth)}BatchFetch(service: "${node.serviceName}") {`);
|
||||
|
||||
for (let i = 0; i < node.entityBatch.aliases.length; i++) {
|
||||
const alias = node.entityBatch.aliases[i];
|
||||
|
||||
lines.push(
|
||||
`${indent(depth + 1)}${alias.alias} {`,
|
||||
`${indent(depth + 2)}paths: [`,
|
||||
...alias.paths.map(path => `${indent(depth + 3)}"${renderFlattenPath(path)}"`),
|
||||
`${indent(depth + 2)}]`,
|
||||
);
|
||||
|
||||
const requires = renderSelectionSet(alias.requires, depth + 3);
|
||||
|
||||
if (requires) {
|
||||
lines.push(`${indent(depth + 2)}{`, requires, `${indent(depth + 2)}}`);
|
||||
}
|
||||
|
||||
if (i < node.entityBatch.aliases.length - 1) {
|
||||
lines.push(`${indent(depth + 1)}}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
lines.push(
|
||||
`${indent(depth + 1)}}`,
|
||||
`${indent(depth + 1)}{`,
|
||||
renderMultilineBlock(
|
||||
print(parse(node.operation)).split('\n').slice(1, -1).join('\n'),
|
||||
depth + 1,
|
||||
),
|
||||
`${indent(depth + 1)}}`,
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
} catch {
|
||||
lines.push(`${indent(depth + 1)}${node.operation}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderFlattenNode(node: FlattenNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${indent(depth)}Flatten(path: "${renderFlattenPath(node.path)}") {`,
|
||||
renderPlanNode(node.node, depth + 1),
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderSequenceNode(node: SequenceNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${indent(depth)}Sequence {`);
|
||||
for (const child of node.nodes) {
|
||||
lines.push(renderPlanNode(child, depth + 1));
|
||||
}
|
||||
lines.push(`${indent(depth)}}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderParallelNode(node: ParallelNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${indent(depth)}Parallel {`);
|
||||
for (const child of node.nodes) {
|
||||
lines.push(renderPlanNode(child, depth + 1));
|
||||
}
|
||||
lines.push(`${indent(depth)}}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderConditionNode(node: ConditionNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (node.ifClause && !node.elseClause) {
|
||||
lines.push(
|
||||
`${indent(depth)}Include(if: $${node.condition}) {`,
|
||||
renderPlanNode(node.ifClause, depth + 1),
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (!node.ifClause && node.elseClause) {
|
||||
lines.push(
|
||||
`${indent(depth)}Skip(if: $${node.condition}) {`,
|
||||
renderPlanNode(node.elseClause, depth + 1),
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (node.ifClause && node.elseClause) {
|
||||
lines.push(
|
||||
`${indent(depth)}Condition(if: $${node.condition}) {`,
|
||||
`${indent(depth + 1)}if {`,
|
||||
renderPlanNode(node.ifClause, depth + 2),
|
||||
`${indent(depth + 1)}}`,
|
||||
`${indent(depth + 1)}else {`,
|
||||
renderPlanNode(node.elseClause, depth + 2),
|
||||
`${indent(depth + 1)}}`,
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return `${indent(depth)}Condition(if: $${node.condition}) {}`;
|
||||
}
|
||||
|
||||
export function renderSubscriptionNode(node: SubscriptionNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${indent(depth)}Subscription {`,
|
||||
`${indent(depth + 1)}primary {`,
|
||||
renderPlanNode(node.primary, depth + 2),
|
||||
`${indent(depth + 1)}}`,
|
||||
`${indent(depth)}}`,
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderDeferNode(node: DeferNodePlan, depth = 0): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${indent(depth)}Defer {`, `${indent(depth + 1)}primary {`);
|
||||
if (node.primary.subselection) {
|
||||
lines.push(`${indent(depth + 2)}subselection: ${JSON.stringify(node.primary.subselection)}`);
|
||||
}
|
||||
if (node.primary.node) {
|
||||
lines.push(renderPlanNode(node.primary.node, depth + 2));
|
||||
}
|
||||
lines.push(`${indent(depth + 1)}}`);
|
||||
|
||||
if (node.deferred.length > 0) {
|
||||
lines.push(`${indent(depth + 1)}deferred {`);
|
||||
for (const d of node.deferred) {
|
||||
lines.push(`${indent(depth + 2)}item {`);
|
||||
if (d.label) lines.push(`${indent(depth + 3)}label: ${JSON.stringify(d.label)}`);
|
||||
lines.push(`${indent(depth + 3)}queryPath: [${d.queryPath.join(', ')}]`);
|
||||
if (d.subselection) {
|
||||
lines.push(`${indent(depth + 3)}subselection: ${JSON.stringify(d.subselection)}`);
|
||||
}
|
||||
if (d.depends.length) {
|
||||
lines.push(
|
||||
`${indent(depth + 3)}depends: [${d.depends
|
||||
.map(x => (x.deferLabel ? `${x.id}:${x.deferLabel}` : x.id))
|
||||
.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
if (d.node) {
|
||||
lines.push(renderPlanNode(d.node, depth + 3));
|
||||
}
|
||||
lines.push(`${indent(depth + 2)}}`);
|
||||
}
|
||||
lines.push(`${indent(depth + 1)}}`);
|
||||
}
|
||||
|
||||
lines.push(`${indent(depth)}}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderMultilineBlock(value: string, depth = 0): string {
|
||||
return value
|
||||
.split('\n')
|
||||
.map(line => `${indent(depth)}${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export type ExtractedOperation = {
|
||||
path: string;
|
||||
nodeKind: 'Fetch' | 'BatchFetch';
|
||||
serviceName: string;
|
||||
operationKind?: string | null;
|
||||
operationName?: string | null;
|
||||
graphql: string;
|
||||
};
|
||||
|
||||
export function extractOperations(plan: QueryPlan): ExtractedOperation[] {
|
||||
if (!plan.node) return [];
|
||||
return extractOperationsFromNode(plan.node, 'root');
|
||||
}
|
||||
|
||||
function extractOperationsFromNode(node: PlanNode, path: string): ExtractedOperation[] {
|
||||
switch (node.kind) {
|
||||
case 'Fetch':
|
||||
return [
|
||||
{
|
||||
path,
|
||||
nodeKind: 'Fetch',
|
||||
serviceName: node.serviceName,
|
||||
operationKind: node.operationKind ?? null,
|
||||
operationName: node.operationName ?? null,
|
||||
graphql: node.operation,
|
||||
},
|
||||
];
|
||||
|
||||
case 'BatchFetch':
|
||||
return [
|
||||
{
|
||||
path,
|
||||
nodeKind: 'BatchFetch',
|
||||
serviceName: node.serviceName,
|
||||
operationKind: node.operationKind ?? null,
|
||||
operationName: node.operationName ?? null,
|
||||
graphql: node.operation,
|
||||
},
|
||||
];
|
||||
|
||||
case 'Flatten':
|
||||
return extractOperationsFromNode(
|
||||
node.node,
|
||||
`${path}.flatten(${renderFlattenPath(node.path)})`,
|
||||
);
|
||||
|
||||
case 'Sequence':
|
||||
return node.nodes.flatMap((child, i) =>
|
||||
extractOperationsFromNode(child, `${path}.sequence[${i}]`),
|
||||
);
|
||||
|
||||
case 'Parallel':
|
||||
return node.nodes.flatMap((child, i) =>
|
||||
extractOperationsFromNode(child, `${path}.parallel[${i}]`),
|
||||
);
|
||||
|
||||
case 'Condition': {
|
||||
const out: ExtractedOperation[] = [];
|
||||
if (node.ifClause) {
|
||||
out.push(...extractOperationsFromNode(node.ifClause, `${path}.if($${node.condition})`));
|
||||
}
|
||||
if (node.elseClause) {
|
||||
out.push(...extractOperationsFromNode(node.elseClause, `${path}.else($${node.condition})`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
case 'Subscription':
|
||||
return extractOperationsFromNode(node.primary, `${path}.subscription.primary`);
|
||||
|
||||
case 'Defer': {
|
||||
const out: ExtractedOperation[] = [];
|
||||
if (node.primary.node) {
|
||||
out.push(...extractOperationsFromNode(node.primary.node, `${path}.defer.primary`));
|
||||
}
|
||||
node.deferred.forEach((d, i) => {
|
||||
if (d.node) {
|
||||
out.push(
|
||||
...extractOperationsFromNode(
|
||||
d.node,
|
||||
`${path}.defer.deferred[${i}]${d.label ? `(${d.label})` : ''}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueryPlanNode extends FlowNode {
|
||||
kind: PlanNode['kind'] | 'Root';
|
||||
children?: QueryPlanNode[];
|
||||
}
|
||||
|
||||
function visitNode(
|
||||
node: PlanNode,
|
||||
parentNode: QueryPlanNode | null,
|
||||
nodes: QueryPlanNode[],
|
||||
contentPrefix?: React.ReactNode,
|
||||
detailsContent?: string,
|
||||
): QueryPlanNode {
|
||||
let result: QueryPlanNode | null = {
|
||||
id: uuidv4(),
|
||||
title: node.kind,
|
||||
kind: node.kind as QueryPlanNode['kind'],
|
||||
};
|
||||
|
||||
switch (node.kind) {
|
||||
case 'Fetch':
|
||||
result.content = () => {
|
||||
const entity = (node.requires?.[0] as SelectionInlineFragment)?.typeCondition;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{contentPrefix}
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Service</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{node.serviceName}
|
||||
</span>
|
||||
</div>
|
||||
{entity && (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Entity</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{entity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-dashed">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
|
||||
Show details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl! max-h-150 h-full w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fetch</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-full overflow-hidden rounded-sm border">
|
||||
<Editor value={detailsContent ?? renderFetchNode(node)} language="graphql" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
break;
|
||||
|
||||
case 'BatchFetch':
|
||||
result.headerSuffix = () => {
|
||||
const totalPaths = node.entityBatch.aliases.reduce(
|
||||
(acc, alias) => acc + alias.paths.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex items-center gap-1 rounded-md p-0.5 pl-1 font-mono text-xs leading-none">
|
||||
Total paths:
|
||||
<div className="bg-primary/10 border-primary text-primary rounded-sm border px-1 text-xs font-medium leading-none">
|
||||
{totalPaths}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
result.content = () => {
|
||||
return (
|
||||
<div className="*:border-border flex flex-col gap-2 *:border-b *:border-dashed *:pb-2 *:last:border-b-0 *:last:pb-0">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Service</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{node.serviceName}
|
||||
</span>
|
||||
</div>
|
||||
{node.entityBatch.aliases.map(alias => {
|
||||
return (
|
||||
<div key={alias.alias} className="grid gap-2">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Path</span>
|
||||
{alias.paths.length > 1 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<div className="bg-card rounded-sm border px-1 text-xs font-medium">
|
||||
{alias.paths.length}
|
||||
</div>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="whitespace-pre-wrap font-mono">
|
||||
{alias.paths.map(path => renderFlattenPath(path)).join(',\n')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{renderFlattenPath(alias.paths[0])}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{renderFlattenPath(alias.paths[0])}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Enitity</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{(alias.requires[0] as SelectionInlineFragment).typeCondition}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="-mt-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="link" className="h-auto w-full pt-2 text-xs">
|
||||
Show details
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-2xl! max-h-150 h-full w-full"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>BatchFetch</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="h-full overflow-hidden rounded-sm border">
|
||||
<Editor value={renderBatchFetchNode(node)} language="graphql" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
break;
|
||||
|
||||
case 'Flatten':
|
||||
result = null;
|
||||
|
||||
visitNode(
|
||||
node.node,
|
||||
result,
|
||||
nodes,
|
||||
<>
|
||||
{contentPrefix}
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Path</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-secondary-foreground cursor-auto overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{renderFlattenPath(node.path)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{renderFlattenPath(node.path)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>,
|
||||
detailsContent ?? renderFlattenNode(node),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'Sequence': {
|
||||
result = null;
|
||||
|
||||
let prevChild: QueryPlanNode | null = null;
|
||||
|
||||
for (let i = 0; i < node.nodes.length; i++) {
|
||||
const child = node.nodes[i];
|
||||
|
||||
const childNode = visitNode(
|
||||
child,
|
||||
prevChild,
|
||||
i === 0 ? [] : nodes,
|
||||
contentPrefix,
|
||||
detailsContent,
|
||||
);
|
||||
|
||||
if (i === 0) {
|
||||
result = childNode;
|
||||
}
|
||||
|
||||
if (prevChild) {
|
||||
prevChild.next = [childNode.id];
|
||||
}
|
||||
|
||||
prevChild = childNode;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Parallel':
|
||||
result.children = [];
|
||||
|
||||
for (const child of node.nodes) {
|
||||
visitNode(child, result, result.children, contentPrefix, detailsContent);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'Condition':
|
||||
result = null;
|
||||
|
||||
if (node.ifClause) {
|
||||
visitNode(
|
||||
node.ifClause,
|
||||
result,
|
||||
nodes,
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Include</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
if: ${node.condition}
|
||||
</span>
|
||||
</div>,
|
||||
renderConditionNode(node),
|
||||
);
|
||||
}
|
||||
if (node.elseClause) {
|
||||
visitNode(
|
||||
node.elseClause,
|
||||
result,
|
||||
nodes,
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-8 overflow-hidden font-mono text-xs">
|
||||
<span className="font-medium">Skip</span>
|
||||
<span className="text-secondary-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
if: ${node.condition}
|
||||
</span>
|
||||
</div>,
|
||||
renderConditionNode(node),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Subscription':
|
||||
visitNode(node.primary, result, nodes, contentPrefix, detailsContent);
|
||||
break;
|
||||
|
||||
case 'Defer':
|
||||
if (node.primary.node) {
|
||||
visitNode(node.primary.node, result, nodes, contentPrefix, detailsContent);
|
||||
}
|
||||
for (const deferred of node.deferred) {
|
||||
if (deferred.node) {
|
||||
visitNode(deferred.node, result, nodes, contentPrefix, detailsContent);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (parentNode && result) {
|
||||
parentNode.next = [...(parentNode.next ?? []), result.id!];
|
||||
}
|
||||
|
||||
if (result) {
|
||||
result.icon = queryPlanNodeIcon(result.kind);
|
||||
nodes.push(result as QueryPlanNode);
|
||||
}
|
||||
|
||||
return result as QueryPlanNode;
|
||||
}
|
||||
|
||||
export const queryPlanNodeIcon = (
|
||||
kind: QueryPlanNode['kind'],
|
||||
): ((props: LucideProps) => React.ReactNode) => {
|
||||
return (props: LucideProps) => {
|
||||
switch (kind) {
|
||||
case 'Root':
|
||||
return (
|
||||
<GraphQLIcon {...props} className={cn(props.className, 'size-6 min-w-6 text-pink-500')} />
|
||||
);
|
||||
case 'Fetch':
|
||||
return <Box {...props} />;
|
||||
case 'BatchFetch':
|
||||
return <Boxes {...props} />;
|
||||
case 'Flatten':
|
||||
return <Layers2Icon {...props} />;
|
||||
case 'Sequence':
|
||||
return <ListOrderedIcon {...props} />;
|
||||
case 'Parallel':
|
||||
return <NetworkIcon {...props} />;
|
||||
case 'Condition':
|
||||
return <GitForkIcon {...props} className={cn('rotate-90', props.className)} />;
|
||||
case 'Subscription':
|
||||
return <UnlinkIcon {...props} />;
|
||||
case 'Defer':
|
||||
return <ClockIcon {...props} />;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function QueryPlanTree(props: { plan: QueryPlan }) {
|
||||
const nodes = useMemo(() => {
|
||||
const nodes: QueryPlanNode[] = [];
|
||||
|
||||
const rootNode: QueryPlanNode = {
|
||||
id: uuidv4(),
|
||||
title: '',
|
||||
kind: 'Root',
|
||||
maxWidth: 42,
|
||||
};
|
||||
|
||||
nodes.push(rootNode);
|
||||
|
||||
if (props.plan.node) {
|
||||
visitNode(props.plan.node, rootNode, nodes);
|
||||
}
|
||||
|
||||
return nodes.map(node => {
|
||||
return {
|
||||
...node,
|
||||
icon: queryPlanNodeIcon(node.kind),
|
||||
} satisfies FlowNode;
|
||||
});
|
||||
}, [props.plan]);
|
||||
|
||||
return <Flow nodes={nodes} />;
|
||||
}
|
||||
|
|
@ -3,9 +3,56 @@ import { useCallback, useState } from 'react';
|
|||
export type LaboratorySettings = {
|
||||
fetch: {
|
||||
credentials: 'include' | 'omit' | 'same-origin';
|
||||
timeout?: number;
|
||||
retry?: number;
|
||||
useGETForQueries?: boolean;
|
||||
};
|
||||
subscriptions: {
|
||||
protocol: 'SSE' | 'GRAPHQL_SSE' | 'WS' | 'LEGACY_WS';
|
||||
};
|
||||
introspection: {
|
||||
method?: 'GET' | 'POST';
|
||||
schemaDescription?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const defaultLaboratorySettings: LaboratorySettings = {
|
||||
fetch: {
|
||||
credentials: 'same-origin',
|
||||
timeout: 10000,
|
||||
retry: 3,
|
||||
useGETForQueries: false,
|
||||
},
|
||||
subscriptions: {
|
||||
protocol: 'WS',
|
||||
},
|
||||
introspection: {
|
||||
method: 'POST',
|
||||
schemaDescription: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const normalizeLaboratorySettings = (
|
||||
settings?: Partial<LaboratorySettings> | null,
|
||||
): LaboratorySettings => ({
|
||||
fetch: {
|
||||
credentials: settings?.fetch?.credentials ?? defaultLaboratorySettings.fetch.credentials,
|
||||
timeout: settings?.fetch?.timeout ?? defaultLaboratorySettings.fetch.timeout,
|
||||
retry: settings?.fetch?.retry ?? defaultLaboratorySettings.fetch.retry,
|
||||
useGETForQueries:
|
||||
settings?.fetch?.useGETForQueries ?? defaultLaboratorySettings.fetch.useGETForQueries,
|
||||
},
|
||||
subscriptions: {
|
||||
protocol: settings?.subscriptions?.protocol ?? defaultLaboratorySettings.subscriptions.protocol,
|
||||
},
|
||||
introspection: {
|
||||
method: settings?.introspection?.method ?? defaultLaboratorySettings.introspection.method,
|
||||
schemaDescription:
|
||||
settings?.introspection?.schemaDescription ??
|
||||
defaultLaboratorySettings.introspection.schemaDescription,
|
||||
},
|
||||
});
|
||||
|
||||
export interface LaboratorySettingsState {
|
||||
settings: LaboratorySettings;
|
||||
}
|
||||
|
|
@ -19,17 +66,14 @@ export const useSettings = (props: {
|
|||
onSettingsChange?: (settings: LaboratorySettings | null) => void;
|
||||
}): LaboratorySettingsState & LaboratorySettingsActions => {
|
||||
const [settings, _setSettings] = useState<LaboratorySettings>(
|
||||
props.defaultSettings ?? {
|
||||
fetch: {
|
||||
credentials: 'same-origin',
|
||||
},
|
||||
},
|
||||
normalizeLaboratorySettings(props.defaultSettings),
|
||||
);
|
||||
|
||||
const setSettings = useCallback(
|
||||
(settings: LaboratorySettings) => {
|
||||
_setSettings(settings);
|
||||
props.onSettingsChange?.(settings);
|
||||
const normalizedSettings = normalizeLaboratorySettings(settings);
|
||||
_setSettings(normalizedSettings);
|
||||
props.onSettingsChange?.(normalizedSettings);
|
||||
},
|
||||
[props],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,3 +17,30 @@ export function splitIdentifier(input: string): string[] {
|
|||
.split(/\s+/)
|
||||
.map(w => w.toLowerCase());
|
||||
}
|
||||
|
||||
export async function asyncInterval(
|
||||
fn: () => Promise<void>,
|
||||
delay: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
while (!signal?.aborted) {
|
||||
await fn();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isAsyncIterable<T>(val: unknown): val is AsyncIterable<T> {
|
||||
return typeof Object(val)[Symbol.asyncIterator] === 'function';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"tslib": "2.8.1",
|
||||
"vitest": "4.0.9"
|
||||
"vitest": "4.1.3"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"typescript": {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue