diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da934e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gocache +.gomodcache +.gopath +bug +data +design +dev-docs +docs +TODO +*.iml +*.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f515ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + go: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node for embedded frontend + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build embedded frontend + run: npm run build + working-directory: frontend + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Test Go + run: go test $(go list ./... | grep -v '/frontend/node_modules/') + + packaging: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Test packaging scripts + run: node --test packaging/cli/tests/*.test.mjs + + frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend + run: npm run build + working-directory: frontend diff --git a/.github/workflows/package-dispatch.yml b/.github/workflows/package-dispatch.yml deleted file mode 100644 index 44cac78..0000000 --- a/.github/workflows/package-dispatch.yml +++ /dev/null @@ -1,312 +0,0 @@ -name: Package FutrixData - -on: - repository_dispatch: - types: - - futrix-package-request - workflow_dispatch: - inputs: - source_repository: - description: "Source repository in owner/name format" - required: true - type: string - source_ref: - description: "Source git ref, for example refs/heads/main or refs/tags/v1.0.0" - required: true - default: "refs/heads/main" - type: string - source_sha: - description: "Optional exact source commit SHA" - required: false - type: string - version: - description: "Optional version override" - required: false - type: string - release: - description: "Create a release in the packaging repository" - required: false - default: false - type: boolean - -permissions: - contents: write - -jobs: - prepare: - runs-on: ubuntu-latest - outputs: - source_repository: ${{ steps.resolve.outputs.source_repository }} - source_ref: ${{ steps.resolve.outputs.source_ref }} - source_sha: ${{ steps.resolve.outputs.source_sha }} - source_checkout_ref: ${{ steps.resolve.outputs.source_checkout_ref }} - version: ${{ steps.resolve.outputs.version }} - release: ${{ steps.resolve.outputs.release }} - source_run_url: ${{ steps.resolve.outputs.source_run_url }} - triggered_by: ${{ steps.resolve.outputs.triggered_by }} - event_name: ${{ steps.resolve.outputs.event_name }} - steps: - - name: Resolve packaging request - id: resolve - shell: bash - run: | - set -euo pipefail - - source_repository="$(jq -r '.client_payload.source_repository // .inputs.source_repository // ""' "$GITHUB_EVENT_PATH")" - source_ref="$(jq -r '.client_payload.source_ref // .inputs.source_ref // ""' "$GITHUB_EVENT_PATH")" - source_sha="$(jq -r '.client_payload.source_sha // .inputs.source_sha // ""' "$GITHUB_EVENT_PATH")" - source_checkout_ref="${source_sha:-$source_ref}" - version="$(jq -r '.client_payload.version // .inputs.version // ""' "$GITHUB_EVENT_PATH")" - release="$(jq -r '.client_payload.release // .inputs.release // "false"' "$GITHUB_EVENT_PATH")" - source_run_url="$(jq -r '.client_payload.source_run_url // ""' "$GITHUB_EVENT_PATH")" - triggered_by="$(jq -r '.client_payload.triggered_by // ""' "$GITHUB_EVENT_PATH")" - event_name="$(jq -r '.client_payload.event_name // ""' "$GITHUB_EVENT_PATH")" - - if [ -z "$triggered_by" ]; then - triggered_by="$GITHUB_ACTOR" - fi - - if [ -z "$event_name" ]; then - event_name="$GITHUB_EVENT_NAME" - fi - - if [ -z "$source_repository" ]; then - echo "source_repository is required" >&2 - exit 1 - fi - - if [ -z "$source_ref" ] && [ -z "$source_sha" ]; then - echo "Either source_ref or source_sha is required" >&2 - exit 1 - fi - - if [ -z "$version" ]; then - if [[ "$source_ref" == refs/tags/* ]]; then - version="${source_ref#refs/tags/}" - elif [ -n "$source_sha" ]; then - version="sha-${source_sha:0:7}" - else - version="manual-${GITHUB_RUN_ID}" - fi - fi - - if [[ "$source_ref" == refs/tags/* ]]; then - release=true - fi - - { - echo "source_repository=$source_repository" - echo "source_ref=$source_ref" - echo "source_sha=$source_sha" - echo "source_checkout_ref=$source_checkout_ref" - echo "version=$version" - echo "release=$release" - echo "source_run_url=$source_run_url" - echo "triggered_by=$triggered_by" - echo "event_name=$event_name" - } >> "$GITHUB_OUTPUT" - - package-macos: - needs: prepare - runs-on: macos-latest - env: - SOURCE_REPOSITORY: ${{ needs.prepare.outputs.source_repository }} - SOURCE_REF: ${{ needs.prepare.outputs.source_ref }} - SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} - SOURCE_CHECKOUT_REF: ${{ needs.prepare.outputs.source_checkout_ref }} - VERSION: ${{ needs.prepare.outputs.version }} - MACOS_WAILS_PLATFORM: ${{ vars.MACOS_WAILS_PLATFORM || 'darwin/universal' }} - MACOS_BUNDLE_ID: ${{ vars.MACOS_BUNDLE_ID || 'com.futrixdata.app' }} - steps: - - name: Checkout packaging repository - uses: actions/checkout@v4 - - - name: Checkout source repository - uses: actions/checkout@v4 - with: - repository: ${{ env.SOURCE_REPOSITORY }} - ref: ${{ env.SOURCE_CHECKOUT_REF }} - token: ${{ secrets.SOURCE_REPO_READ_TOKEN }} - path: source - fetch-depth: 1 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: source/go.mod - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: source/frontend/package-lock.json - - - name: Install Wails CLI - shell: bash - run: | - set -euo pipefail - go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - - name: Import macOS signing certificate - shell: bash - env: - MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} - MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - run: | - set -euo pipefail - CERT_PATH="$RUNNER_TEMP/macos-signing-cert.p12" - KEYCHAIN_PATH="$RUNNER_TEMP/packaging.keychain-db" - KEYCHAIN_PASSWORD="$(uuidgen)" - - MACOS_CERTIFICATE_P12_BASE64="$MACOS_CERTIFICATE_P12_BASE64" \ - python3 - <<'PY' > "$CERT_PATH" -import base64 -import os -import sys - -sys.stdout.buffer.write(base64.b64decode(os.environ["MACOS_CERTIFICATE_P12_BASE64"])) -PY - - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security import "$CERT_PATH" -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" - security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"') - security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - - name: Decode App Store Connect key - shell: bash - env: - MACOS_NOTARY_PRIVATE_KEY_BASE64: ${{ secrets.MACOS_NOTARY_PRIVATE_KEY_BASE64 }} - run: | - set -euo pipefail - NOTARY_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" - MACOS_NOTARY_PRIVATE_KEY_BASE64="$MACOS_NOTARY_PRIVATE_KEY_BASE64" \ - python3 - <<'PY' > "$NOTARY_KEY_PATH" -import base64 -import os -import sys - -sys.stdout.buffer.write(base64.b64decode(os.environ["MACOS_NOTARY_PRIVATE_KEY_BASE64"])) -PY - echo "MACOS_NOTARY_KEY_FILE=$NOTARY_KEY_PATH" >> "$GITHUB_ENV" - - - name: Package macOS app - shell: bash - env: - MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_ISSUER: ${{ secrets.MACOS_NOTARY_ISSUER }} - PRODUCT_NAME: ${{ vars.PRODUCT_NAME || 'FutrixData' }} - run: ./scripts/package-macos.sh - - - name: Upload macOS artifacts - uses: actions/upload-artifact@v4 - with: - name: dist-macos-${{ env.VERSION }} - path: dist/macos/* - if-no-files-found: error - - package-windows: - needs: prepare - runs-on: windows-latest - env: - SOURCE_REPOSITORY: ${{ needs.prepare.outputs.source_repository }} - SOURCE_REF: ${{ needs.prepare.outputs.source_ref }} - SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} - SOURCE_CHECKOUT_REF: ${{ needs.prepare.outputs.source_checkout_ref }} - VERSION: ${{ needs.prepare.outputs.version }} - WINDOWS_WAILS_PLATFORM: ${{ vars.WINDOWS_WAILS_PLATFORM || 'windows/amd64' }} - WINDOWS_TIMESTAMP_URL: ${{ vars.WINDOWS_TIMESTAMP_URL || 'http://timestamp.digicert.com' }} - steps: - - name: Checkout packaging repository - uses: actions/checkout@v4 - - - name: Checkout source repository - uses: actions/checkout@v4 - with: - repository: ${{ env.SOURCE_REPOSITORY }} - ref: ${{ env.SOURCE_CHECKOUT_REF }} - token: ${{ secrets.SOURCE_REPO_READ_TOKEN }} - path: source - fetch-depth: 1 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: source/go.mod - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: source/frontend/package-lock.json - - - name: Install Wails CLI - shell: pwsh - run: | - go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 - "$((go env GOPATH) -replace '\\','/')/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Decode Windows signing certificate - shell: pwsh - env: - WINDOWS_CERTIFICATE_PFX_BASE64: ${{ secrets.WINDOWS_CERTIFICATE_PFX_BASE64 }} - run: | - $certPath = Join-Path $env:RUNNER_TEMP "windows-signing-cert.pfx" - [System.IO.File]::WriteAllBytes($certPath, [System.Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_PFX_BASE64)) - "WINDOWS_CERTIFICATE_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Package Windows app - shell: pwsh - env: - PRODUCT_NAME: ${{ vars.PRODUCT_NAME || 'FutrixData' }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - run: ./scripts/package-windows.ps1 - - - name: Upload Windows artifacts - uses: actions/upload-artifact@v4 - with: - name: dist-windows-${{ env.VERSION }} - path: dist/windows/* - if-no-files-found: error - - release: - needs: - - prepare - - package-macos - - package-windows - if: ${{ needs.prepare.outputs.release == 'true' }} - runs-on: ubuntu-latest - steps: - - name: Download packaged artifacts - uses: actions/download-artifact@v4 - with: - pattern: dist-* - path: release-assets - merge-multiple: true - - - name: Create release notes - shell: bash - run: | - set -euo pipefail - { - echo "Source repository: ${{ needs.prepare.outputs.source_repository }}" - echo "Source ref: ${{ needs.prepare.outputs.source_ref }}" - echo "Source sha: ${{ needs.prepare.outputs.source_sha }}" - echo "Triggered by: ${{ needs.prepare.outputs.triggered_by }}" - if [ -n "${{ needs.prepare.outputs.source_run_url }}" ]; then - echo "Source workflow: ${{ needs.prepare.outputs.source_run_url }}" - fi - } > RELEASE_NOTES.txt - - - name: Publish GitHub release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.prepare.outputs.version }} - name: FutrixData ${{ needs.prepare.outputs.version }} - body_path: RELEASE_NOTES.txt - files: release-assets/* diff --git a/.github/workflows/release-futrixdata-cli.yml b/.github/workflows/release-futrixdata-cli.yml new file mode 100644 index 0000000..a744a82 --- /dev/null +++ b/.github/workflows/release-futrixdata-cli.yml @@ -0,0 +1,69 @@ +name: release-futrixdata-cli + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: Release tag to publish, for example v1.2.3 + required: true + type: string + publish_npm: + description: Publish the generated npm package when NPM_TOKEN is configured + required: false + default: true + type: boolean + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + env: + RELEASE_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Build CLI release assets + run: | + sh scripts/release-futrixdata-cli.sh \ + --version "${RELEASE_VERSION}" \ + --output dist/cli + + - name: Create or update GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + mapfile -t assets < <(find dist/cli -type f | sort) + if [ "${#assets[@]}" -eq 0 ]; then + echo "no release assets found" >&2 + exit 1 + fi + if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then + gh release upload "${RELEASE_VERSION}" "${assets[@]}" --clobber + else + gh release create "${RELEASE_VERSION}" "${assets[@]}" + fi + + - name: Publish npm package + if: ${{ (github.event_name == 'push' || inputs.publish_npm) && secrets.NPM_TOKEN != '' }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + npm publish dist/cli/npm/*.tgz --access public diff --git a/.gitignore b/.gitignore index 7ac3cf9..6a17110 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,73 @@ -dist/ -artifacts/ -release-assets/ +HELP.md +.gradle +build/bin/ +build/darwin/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ +!packaging/npm/futrixdata-cli/bin/ +!packaging/npm/futrixdata-cli/bin/futrixdata-cli.mjs + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +.gocache/ +.gomodcache/ +.gopath/ + +# Runtime data (credentials, caches, history, logs, prompts, local state) +/data/ + +*.sbin +*.bin + +# Runtime / build artifacts +node_modules/ +.npm-cache/ +npm-cache/ +frontend/dist/ +frontend/wailsjs/ +frontend/package.json.md5 + +# Local agent/test artifacts +output/ +.worktrees .worktrees/ -.DS_Store + +# Playwright MCP logs +.playwright-mcp/ + +# Claude context +.context/ + +# Built binaries at repo root +/futrixdata-cli +/http +.gstack/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcfaefc..5ad637f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,37 @@ # Contributing -This repository accepts focused improvements to public specs, examples, test vectors, and verifier packages. +FutrixData welcomes focused contributions that improve the local AI data +gateway, desktop runtime, datasource support, safety checks, packaging, and +developer experience. -Good contributions: +Good contributions include: -- clarify a documented security limit; -- add a small test vector; -- improve verifier error reporting; -- fix a portable rule or masking edge case. +- fixing datasource adapters or schema discovery behavior; +- improving risk-rule detection, approval behavior, masking, or auditability; +- adding tests for agent, CLI, IPC, frontend, or packaging flows; +- improving desktop UI clarity without weakening safety controls; +- tightening setup, build, release, or troubleshooting instructions. -Please avoid contributions that add product-specific desktop, Enterprise, billing, signing, deployment, or credential-management code. Those areas are intentionally outside this public package. +Please do not commit: + +- database credentials, API keys, access tokens, private logs, customer data, or + local datasource files; +- generated desktop assets such as `frontend/dist/`, `frontend/wailsjs/`, or + `build/bin/`; +- local agent skill directories, internal task notes, scratch plans, or runtime + artifacts; +- changes that bypass risk checks, masking, agent identity, or approval gates + without an explicit security rationale. + +Before opening a pull request, run the relevant checks: + +```bash +go test $(go list ./... | grep -v '/frontend/node_modules/') +node --test packaging/cli/tests/*.test.mjs +npm --prefix frontend run build +``` + +UI changes should also run the relevant Vitest files with +`npm --prefix frontend run test -- ` and be verified in the Wails +desktop runtime, because the desktop shell, IPC daemon, generated bindings, and +packaged frontend assets are part of the product behavior. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..285c51c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24.3 AS build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/futrixdata-http ./cmd/http + +FROM gcr.io/distroless/base-debian12:nonroot +WORKDIR /app +COPY --from=build /out/futrixdata-http /app/futrixdata-http +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/app/futrixdata-http"] diff --git a/NOTICE b/NOTICE index fea1a81..7ab1343 100644 --- a/NOTICE +++ b/NOTICE @@ -1,7 +1,6 @@ -FutrixData Security Package +FutrixData Copyright 2026 FutrixData -This repository contains public security specifications, verification -utilities, and small reusable packages extracted from FutrixData. It does not -contain the proprietary FutrixData desktop app, enterprise server, licensing, -billing, account, signing, or release infrastructure. +This repository contains the open-source FutrixData desktop application, +local runtime, CLI, datasource adapters, packaging helpers, and supporting +development assets. diff --git a/README.md b/README.md index 6eef6b6..5256d53 100644 --- a/README.md +++ b/README.md @@ -1,142 +1,172 @@ +# FutrixData +

- FutrixData Gateway connecting AI agents to governed data sources + FutrixData gateway connecting AI agents to governed data sources

-

FutrixData Security Package

-

- Public security specifications, verifiers, protocol types, masking code, and a partial risk-engine core for FutrixData. + An open-source AI data gateway for governed agent access to real databases.

License: Apache-2.0 Go 1.23+ - FutrixData docs - Assurance matrix + Vue 3 + Wails desktop app

-FutrixData is an AI data gateway for teams that want agents to work with real databases without handing raw credentials or unrestricted execution power to the agent. This repository is the **inspectable public security package**: the pieces a security reviewer, procurement team, or integrator can read, run, and compare against FutrixData product behavior during evaluation. +FutrixData sits between AI agents and production data systems. Agents get a consistent tool surface for querying and analyzing data, while teams keep credentials, risk checks, masking, approvals, and audit trails under one local control plane. -> **Scope:** this repository is Apache-2.0. The FutrixData desktop application and FutrixData Enterprise Edition remain commercial, proprietary products under their own license terms. +The goal is simple: let agents work with useful data without handing them raw database credentials or unlimited execution power. -## Product References +## Why FutrixData -Start with the official product docs when evaluating what this package supports: +AI agents are moving from text assistants into operational workflows. Direct database access creates several hard problems: -- [FutrixData product site](https://futrixdata.com/) -- [Technical overview](https://futrixdata.com/doc/futrixdata-technical-overview) -- [Database risk control engine](https://futrixdata.com/doc/database-risk-control-engine) -- [Data sensitivity classification](https://futrixdata.com/doc/data-sensitivity-classification) -- [FutrixData Enterprise Edition](https://futrixdata.com/doc/futrixdata-enterprise-edition) +- **Credential sprawl:** every agent, IDE, script, and workflow can end up holding its own database password or cloud token. +- **Unsafe execution:** generated SQL, Redis commands, or datasource requests may delete data, run expensive scans, or bypass review. +- **Sensitive output:** agent responses can leak emails, phone numbers, tokens, addresses, payment fields, or internal identifiers. +- **Weak accountability:** without a gateway, it is hard to answer who asked for a query, which tool executed it, and what policy decision was made. -## Quick Start +FutrixData centralizes that boundary. The database stays behind a governed local runtime; agents interact through FutrixData's CLI, MCP-compatible tool layer, HTTP service, or desktop UI. -Run the public verification suite: +## Core Features -```bash -go test ./... +| Area | What FutrixData provides | +| --- | --- | +| Data gateway | One runtime for desktop UI, CLI, HTTP, daemon handoff, and agent tool execution. | +| Datasource adapters | MySQL, PostgreSQL, MongoDB, Redis, Elasticsearch, DynamoDB, ChromaDB, and Cloudflare D1 surfaces. | +| Risk controls | Statement analysis, dangerous-operation detection, approval-aware tool execution, and rule-driven decisions. | +| Sensitivity controls | Schema-aware classification, masking policies, deterministic local masking, and no raw data in classification prompts. | +| Agent identity | Access-key based attribution for agent calls, local authorization flows, revocation, and audit-friendly request envelopes. | +| AI workspace | Built-in chat, tool calling, streaming responses, knowledge retrieval, visualization output, and configurable datasource-aware prompting. | +| Local-first operations | Runtime files, datasource state, logs, key material, and agent audit state are kept in the user's local data directory by default. | + +## Supported Data Sources + +| Datasource | Connect | Query / command | Schema discovery | Risk checks | +| --- | --- | --- | --- | --- | +| MySQL | Yes | SQL | Yes | Yes | +| PostgreSQL | Yes | SQL | Yes | Yes | +| MongoDB | Yes | Mongo shell-like operations | Yes | Yes | +| Redis | Yes | Redis commands | Key and command context | Yes | +| Elasticsearch | Yes | DSL / index operations | Yes | Yes | +| DynamoDB | Yes | Table operations | Yes | Yes | +| ChromaDB | Yes | Collection requests | Yes | Yes | +| Cloudflare D1 | Yes | SQL / REST-backed operations | Yes | Yes | + +## How It Works + +```text +AI agent / CLI / UI + | + v +FutrixData runtime + - agent identity and authorization + - datasource secret resolution + - statement parsing and risk checks + - sensitivity classification and masking + - execution audit trail + | + v +Databases, caches, indexes, warehouses, and vector stores ``` -Verify the sanitized product-export evidence bundle: +The same runtime backs the Wails desktop app, the `futrixdata-cli` command, the local daemon, and the optional HTTP server. This keeps policy behavior consistent across human and agent entry points. + +## Quick Start + +### Requirements + +- Go 1.23 or newer. The repository currently pins the Go toolchain to `go1.24.3`. +- Node.js 20 or newer. +- Wails v2.11 for desktop development. + +Install Wails if it is not already available: ```bash -go run ./cmd/futrix-evidence-verify ./examples/product-export +go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0 ``` -Verify an audit log hash chain: +Install frontend dependencies: ```bash -go run ./cmd/futrix-audit-verify ./examples/audit-log/valid.jsonl +cd frontend +npm install +cd .. ``` -Verify downloaded release artifacts when a `SHA256SUMS.txt` file is present: +Run the desktop app in development mode: ```bash -bash ./release-verification/verify-checksums.sh /path/to/downloads +wails dev ``` -## What You Can Inspect +Build the frontend and run backend tests: -| Area | Public path | What it proves | -| --- | --- | --- | -| Audit chain | `pkg/auditchain`, `cmd/futrix-audit-verify` | Local hash-chain audit format and verifier behavior. | -| PII masking | `pkg/masking` | L1-L5 sensitivity model and deterministic `masked:v1:` HMAC output. | -| Partial risk engine | `pkg/riskengine` | Rule model, lightweight parser, matching priority, and allow/warn/approval/block decisions. | -| Agent protocol | `pkg/protocol` | Tool names, response envelopes, approval payloads, errors, audit IDs, and risk attribution. | -| Evidence verifier | `pkg/evidence`, `cmd/futrix-evidence-verify` | End-to-end checks for audit, masking, block, and approval examples. | -| Release verification | `release-verification/verify-checksums.sh` | Checksum validation for published release assets. | +```bash +npm --prefix frontend run build +go test $(go list ./... | grep -v '/frontend/node_modules/') +node --test packaging/cli/tests/*.test.mjs +``` -## Buyer Evaluation Workflow +Build the desktop app: -Use this repository as the public part of an Enterprise security review: +```bash +wails build +``` -1. Read the [assurance matrix](docs/assurance-matrix.md) to map product claims to code and verification steps. -2. Run `go test ./...` to confirm the public packages compile and pass. -3. Run `go run ./cmd/futrix-evidence-verify ./examples/product-export` to validate the evidence bundle. -4. During POC, ask FutrixData for equivalent exports from a disposable datasource: - - an agent query with masked columns; - - a destructive statement that is blocked; - - a statement held for approval with `riskAttribution`; - - an exported agent audit log that can be checked with `futrix-audit-verify`. +Run the optional HTTP service: -## How FutrixData Uses These Concepts +```bash +go run ./cmd/http +``` -Agents call FutrixData over MCP, Skill, CLI, or HTTP instead of holding database credentials directly. FutrixData attributes each call to an agent identity, evaluates risk before execution, applies approval gates when needed, masks sensitive fields before agent egress, and records activity in an audit log with a local hash chain. +Build and run the CLI during development: -This repository exposes the reviewable contracts behind that flow. The commercial products provide the full runtime: datasource adapters, richer parser integrations, EXPLAIN probes, trust-mode storage, approval routing, daemon behavior, UI, Enterprise deployment, SSO/RBAC, and operational controls. +```bash +go run ./cmd/futrixdata-cli --help +``` ## Repository Layout ```text -cmd/futrix-audit-verify/ Standalone audit-log verifier -cmd/futrix-evidence-verify/ Evidence-bundle verifier CLI -pkg/auditchain/ Local audit hash-chain verifier -pkg/masking/ Deterministic field masking -pkg/riskengine/ Portable risk-engine core -pkg/protocol/ Public agent tool protocol types -pkg/evidence/ Evidence-bundle verifier package -docs/ Specs, assurance matrix, and scope notes -examples/ Audit, risk-rule, and product-export fixtures -release-verification/ Checksum verification helper +frontend/ Vue 3 desktop UI, Vite build, Vitest tests +internal/ Core runtime, datasource adapters, risk, masking, IPC, daemon, MCP, auth +cmd/futrixdata-cli/ CLI entry point for agent and operator workflows +cmd/http/ Optional HTTP server entry point +packaging/ CLI and npm packaging helpers +scripts/ Release and install helper scripts +build/ Wails application icons and static build metadata +docs/assets/ README media assets ``` -## What Is Not Open +The repository intentionally excludes the root `data/` directory and other local runtime files such as datasource records, logs, auth sessions, schema caches, generated chat history, local prompts, agent-specific skills, internal task notes, and temporary build outputs. + +## Development Notes -This repository does not include the complete FutrixData product. The following remain proprietary: +FutrixData is a local-first desktop project. During development, the app may create ignored files under `data/`, `frontend/dist/`, `frontend/wailsjs/`, and `build/bin/`. These are runtime or generated artifacts and must not be committed. -- desktop UI, datasource adapters, and credential storage; -- account, license, billing, and entitlement flows; -- Enterprise deployment, RBAC, SSO, and tenant administration; -- signing, notarization, release credentials, and private build systems. +Common checks: -The boundary is intentional: the public package supports review and verification of key security claims without making the full commercial product reconstructable from this repository alone. +```bash +go test $(go list ./... | grep -v '/frontend/node_modules/') +node --test packaging/cli/tests/*.test.mjs +npm --prefix frontend run build +``` -## Known Limits +For UI changes, run the relevant Vitest files with `npm --prefix frontend run test -- ` and validate the Wails desktop runtime, not only the Vite browser preview. The desktop shell, IPC daemon, packaged assets, and frontend bindings are part of the product surface. -- **Local audit hash chains are not remote notarization.** They detect changes to the current file, but a fully privileged local attacker can rewrite the file and recompute hashes unless an external anchor is used. -- **Deterministic masking is not anonymization.** It preserves equality for agent analysis, but low-cardinality values remain guessable by enumeration. -- **The public risk engine is a portable subset.** The commercial product adds live datasource execution, EXPLAIN probes, trust modes, approval routing, and Enterprise policy controls. +## Contributing -## Specifications +Issues and pull requests are welcome. Useful contributions include datasource adapter fixes, risk-rule coverage, masking behavior improvements, desktop workflow polish, tests, packaging fixes, and documentation that helps operators run FutrixData safely. -- [Open-source scope analysis](docs/open-source-scope.md) -- [Assurance matrix](docs/assurance-matrix.md) -- [Production consistency statement](docs/production-consistency.md) -- [Evidence bundle](docs/evidence-bundle.md) -- [Threat model](docs/threat-model.md) -- [Audit-chain specification](docs/audit-chain.md) -- [Masking specification](docs/masking.md) -- [Partial risk-engine specification](docs/risk-engine.md) -- [Agent protocol](docs/agent-protocol.md) +Before contributing, read [CONTRIBUTING.md](CONTRIBUTING.md) and keep secrets, credentials, private logs, customer data, and local runtime files out of public issues and pull requests. -## Contributing and Security +## Security -- Contribution guidelines: [CONTRIBUTING.md](CONTRIBUTING.md) -- Security policy: [SECURITY.md](SECURITY.md) -- Attribution notice: [NOTICE](NOTICE) +FutrixData is a security-sensitive project. Please do not disclose exploitable details, credentials, customer data, or private logs in public issues. Follow [SECURITY.md](SECURITY.md) for responsible reporting. ## License -This repository is licensed under Apache-2.0. See [LICENSE](LICENSE). - -The FutrixData desktop application and FutrixData Enterprise Edition remain commercial products under their own license terms. +FutrixData is licensed under the Apache License 2.0. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 9c1e838..ffabdc8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,22 @@ -# Security policy +# Security Policy -Please do not post secrets, credentials, private logs, customer data, or exploitable details in public issues. +FutrixData is a security-sensitive data gateway. Please do not post secrets, +credentials, private logs, customer data, exploit details, or production +connection strings in public issues, pull requests, or discussions. -For vulnerability reports, use a private disclosure channel controlled by FutrixData before publishing details. +Report suspected vulnerabilities through a private disclosure channel controlled +by FutrixData before publishing details. Include only the minimum information +needed to reproduce the issue, and redact datasource names, hostnames, tokens, +query results, and customer identifiers. -This public repository covers security specs and verification packages. Findings in the commercial desktop app or Enterprise server may require private reproduction details and should be reported privately. +Areas that are especially sensitive: + +- datasource credential handling and secret providers; +- agent access keys, authorization, revocation, and IPC envelopes; +- risk-rule bypasses or unsafe auto-execution; +- sensitivity classification and masking failures; +- audit-chain integrity and log redaction; +- release, packaging, signing, and update behavior. + +If you are unsure whether a report contains sensitive material, treat it as +private first. diff --git a/app.go b/app.go new file mode 100644 index 0000000..bace636 --- /dev/null +++ b/app.go @@ -0,0 +1,342 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "futrixdata/platform/internal/ai" + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/aiconfig" + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/datasourceops" + "futrixdata/platform/internal/datasourcesecrets" + "futrixdata/platform/internal/diagnostics" + "futrixdata/platform/internal/history" + "futrixdata/platform/internal/keyring" + "futrixdata/platform/internal/localcrypto" + "futrixdata/platform/internal/observability" + "futrixdata/platform/internal/redisproto" + "futrixdata/platform/internal/riskengine" + "futrixdata/platform/internal/schemaprivacy" + "futrixdata/platform/internal/secrets" + "futrixdata/platform/internal/sensitivity" + "futrixdata/platform/internal/startuprecovery" + "futrixdata/platform/internal/updater" + "futrixdata/platform/internal/userkb" + "futrixdata/platform/internal/version" + + "github.com/pkg/browser" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type App struct { + ctx context.Context + cfg Config + store *datasource.Store + aiConfigStore *aiconfig.Store + authStore *auth.Store + authService *auth.Service + updaterService *updater.Service + schemaKB *schemaKnowledgeManager + aiChat *aichat.Service + aiChatDiag *aichat.FileDiagnostics + aiChatStreams *aiChatStreamRegistry + manager *console.Manager + riskEngine *riskengine.Engine + riskStore *riskengine.Store + redisDocs *console.RedisCommandDocsStore + entityCache *console.EntitySchemaCacheStore + historyStore *history.Store + redisProtoStore *redisproto.Store + datasourceSecrets *datasourcesecrets.Manager + secretConfigs *secrets.ProviderConfigStore + fallbackAI *ai.Client + userKB *userkb.Manager + sensitivityMgr *sensitivity.Manager + schemaPrivacy *schemaprivacy.AuditStore + // toolService is the same Service surface used by daemon IPC handlers, + // CLI tool calls, and MCP. We build it once here so the embedded daemon + // goroutine started in main.go shares the GUI's stores instead of + // loading datasources.json a second time. + toolService *datasourceops.Service + runCommand func(ctx context.Context, command []string) ([]byte, error) + httpClient *http.Client + logsRoot string + infoLog *log.Logger + errorLog *log.Logger + diagnostics *diagnostics.Store + sessionTracker *observability.SessionTracker + launchArgs []string + emitEvent func(ctx context.Context, eventName string, data ...interface{}) + startupMu sync.RWMutex + startupState string + startupError *startuprecovery.Info + movedAside *localcrypto.MoveAsideResult + daemonCancel context.CancelFunc + daemonDone chan struct{} +} + +func NewApp(cfg Config) (*App, error) { + resolvedDataPath := bootstrap.ResolveDataPath(cfg.DataPath) + if _, err := localcrypto.Init(resolvedDataPath); err != nil { + return nil, err + } + maskingSecret, err := keyring.EnsureMaskingSecret() + if err != nil { + log.Printf("masking secret keyring unavailable, using legacy masking secret fallback: %v", err) + } + + runtimeBundle, err := bootstrap.NewRuntime(bootstrap.Config{DataPath: resolvedDataPath}) + if err != nil { + return nil, err + } + cfg.DataPath = runtimeBundle.DataPath + store := runtimeBundle.Store + aiConfigStore := runtimeBundle.AIConfigStore + redisDocs := runtimeBundle.RedisDocs + entityCache := runtimeBundle.EntityCache + historyStore := runtimeBundle.HistoryStore + redisProtoStore := runtimeBundle.RedisProtoStore + manager := runtimeBundle.Manager + riskEng := runtimeBundle.RiskEngine + riskGuard := runtimeBundle.RiskGuard + riskStore := riskengine.NewStore(bootstrap.RiskRulesPath(cfg.DataPath)) + _ = riskStore.Load() // best-effort; missing dir is fine + authStore := auth.NewStore(auth.PathForDataPath(cfg.DataPath)) + if err := authStore.Load(); err != nil { + return nil, fmt.Errorf("load auth session: %w", err) + } + authService := auth.NewService(auth.ServiceConfig{ + BaseURL: resolveAuthBaseURL(cfg), + Store: authStore, + OpenURL: browser.OpenURL, + HTTPClient: &http.Client{Timeout: 20 * time.Second}, + }) + sensitivityStore := sensitivity.NewStore(bootstrap.SensitivityStorePath(cfg.DataPath)) + if err := sensitivityStore.Load(); err != nil { + return nil, fmt.Errorf("load sensitivity store: %w", err) + } + legacyMaskingSecret := func() string { + if authStore == nil { + return "" + } + state := authStore.Current() + if state.Session == nil { + return "" + } + return state.Session.User.ID + } + masking := sensitivity.NewMaskingProcessorWithLegacyFallback(sensitivityStore, maskingSecret, legacyMaskingSecret) + + modelResolver := newAppAIChatModelResolver(cfg, aiConfigStore) + userKBRoot := bootstrap.UserKBRoot(cfg.DataPath) + schemaKBRoot := bootstrap.SchemaKnowledgeRoot(cfg.DataPath) + schemaPrivacyAudit := schemaprivacy.NewAuditStore(bootstrap.SchemaPrivacyAuditPath(cfg.DataPath)) + logsRoot := resolveLogsRoot(cfg) + infoLog := newAppLogger(logsRoot, "info.log") + errorLog := newAppLogger(logsRoot, "error.log") + diagnosticsStore := diagnostics.NewStore(diagnostics.PathForDataPath(cfg.DataPath)) + datasourceTimingStarter := newAppDatasourceTimingStarter(diagnosticsStore, infoLog) + schemaKB := newSchemaKnowledgeManager(schemaKBRoot, modelResolver) + if schemaKB != nil { + schemaKB.SetSchemaPrivacy(schemaPrivacyAudit, providerSummaryFromResolver(aiConfigStore)) + schemaKB.SetDatasourceLookup(store.Get) + } + aiChat := aichat.NewService(modelResolver, newAppAIChatTools(store, manager, redisDocs, schemaKB, masking, authStore, schemaPrivacyAudit, providerSummaryFromResolver(aiConfigStore), runtimeBundle.DatasourceSecrets, datasourceTimingStarter)) + userKB, err := userkb.NewManager(userkb.ManagerConfig{ + Root: userKBRoot, + ModelResolver: newUserKBModelResolver(modelResolver), + }) + if err != nil { + return nil, fmt.Errorf("load user knowledge base: %w", err) + } + aiChat.SetUserKnowledgeDir(filepath.Join(userKBRoot, "parsed", "scopes")) + if prompt := loadAIChatSystemPrompt(cfg); strings.TrimSpace(prompt) != "" { + aiChat.SetBaseSystemPrompt(prompt) + } + aiChat.SetPromptModules(loadAIChatPromptModules(cfg)) + aiChat.SetKnowledgeDir(resolveAIChatKnowledgeDir(cfg)) + aiChat.SetThreadStoreDir(filepath.Join(filepath.Dir(cfg.DataPath), "ai-chat")) + aiChatDiag := aichat.NewFileDiagnostics(aichat.FileDiagnosticsConfig{ + Dir: filepath.Join(filepath.Dir(cfg.DataPath), "logs", "aichat"), + IncludeRaw: strings.TrimSpace(os.Getenv("FUTRIX_AI_CHAT_LOG_RAW")) == "1", + AfterWrite: func() { + _ = observability.PruneLogs(logsRoot, defaultLogsMaxBytes, observability.DefaultPreserveBaseNames()) + }, + }) + aiChat.SetDiagnostics(aiChatDiag) + aiChat.SetRiskGuard(riskGuard) + sensitivityMgr := sensitivity.NewManager(sensitivityStore, &sensitivityModelBridge{resolver: modelResolver}) + + // Build the tool-service surface from the same store/manager instances + // the GUI uses. The embedded daemon goroutine started in main.go reuses + // this so IPC tool.call dispatches and Wails facade calls operate on a + // single in-memory graph. + toolService := datasourceops.NewService(datasourceops.Config{ + Store: store, + Manager: manager, + RedisDocs: redisDocs, + AuthStore: authStore, + AuthBaseURL: resolveAuthBaseURL(cfg), + SchemaKnowledgeRoot: bootstrap.SchemaKnowledgeRoot(cfg.DataPath), + SensitivityStore: sensitivityStore, + MaskingSecret: maskingSecret, + RiskEngine: riskEng, + RiskStore: riskStore, + RiskGuard: riskGuard, + RedisProtoStore: redisProtoStore, + DatasourceSecrets: runtimeBundle.DatasourceSecrets, + InfoLog: infoLog, + ErrorLog: errorLog, + DatasourceTimingEnabled: diagnosticsStore.DatasourceTimingLogEnabled, + }) + + return &App{ + cfg: cfg, + store: store, + aiConfigStore: aiConfigStore, + authStore: authStore, + authService: authService, + updaterService: updater.NewService(authService, version.Version), + schemaKB: schemaKB, + aiChat: aiChat, + aiChatDiag: aiChatDiag, + aiChatStreams: newAIChatStreamRegistry(), + manager: manager, + riskEngine: riskEng, + riskStore: riskStore, + redisDocs: redisDocs, + entityCache: entityCache, + historyStore: historyStore, + redisProtoStore: redisProtoStore, + datasourceSecrets: runtimeBundle.DatasourceSecrets, + secretConfigs: runtimeBundle.SecretConfigs, + fallbackAI: ai.NewClient(buildAIConfig(cfg)), + userKB: userKB, + sensitivityMgr: sensitivityMgr, + schemaPrivacy: schemaPrivacyAudit, + toolService: toolService, + runCommand: appRunCommand, + httpClient: &http.Client{Timeout: 20 * time.Second}, + logsRoot: logsRoot, + infoLog: infoLog, + errorLog: errorLog, + diagnostics: diagnosticsStore, + sessionTracker: observability.NewSessionTracker(logsRoot), + }, nil +} + +func resolveAuthBaseURL(cfg Config) string { + if value := strings.TrimSpace(cfg.AuthBaseURL); value != "" { + return value + } + if value := strings.TrimSpace(os.Getenv("FUTRIX_AUTH_BASE_URL")); value != "" { + return value + } + return auth.DefaultBaseURL +} + +func loadAIChatSystemPrompt(cfg Config) string { + path := strings.TrimSpace(os.Getenv("FUTRIX_AI_CHAT_PROMPT_PATH")) + if path == "" { + path = strings.TrimSpace(cfg.AIChatPromptPath) + } + if path == "" { + return "" + } + content, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +func loadAIChatPromptModules(cfg Config) aichat.PromptModules { + promptsDir := strings.TrimSpace(os.Getenv("FUTRIX_AI_CHAT_PROMPT_MODULES_DIR")) + if promptsDir == "" { + promptsDir = strings.TrimSpace(cfg.AIChatPromptModulesDir) + } + knowledgeDir := resolveAIChatKnowledgeDir(cfg) + + modules, err := aichat.LoadPromptModules(aichat.PromptModulesLoadConfig{ + PromptsDir: promptsDir, + KnowledgeDir: knowledgeDir, + MaxBytes: 24_000, + }) + if err != nil { + return aichat.PromptModules{} + } + return modules +} + +func resolveAIChatKnowledgeDir(cfg Config) string { + knowledgeDir := strings.TrimSpace(os.Getenv("FUTRIX_AI_CHAT_KNOWLEDGE_DIR")) + if knowledgeDir == "" { + knowledgeDir = strings.TrimSpace(cfg.AIChatKnowledgeDir) + } + if knowledgeDir == "" { + knowledgeDir = "data/ai-chat-knowledge" + } + return knowledgeDir +} + +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + a.emitEvent = wailsruntime.EventsEmit + a.startRuntimeInitialization() +} + +func (a *App) runtimeStartupReady(ctx context.Context) { + if a.sessionTracker != nil { + abnormal, previous, err := a.sessionTracker.Start() + if err != nil { + a.logErrorf("source=session event=session_start_failed error=%s", logField(err.Error())) + } else if abnormal { + a.logErrorf("source=session event=abnormal_exit_detected previous_pid=%d previous_started_at=%s", previous.PID, logField(previous.StartedAt)) + imported, importErr := observability.ImportPlatformCrashReports(a.logsRoot, "FutrixData", parseSessionStartedAt(previous.StartedAt)) + if importErr != nil { + a.logErrorf("source=crash event=import_platform_reports_failed error=%s", logField(importErr.Error())) + } else if imported > 0 { + a.logErrorf("source=crash event=import_platform_reports imported=%d", imported) + } + } + } + if a.aiChat != nil && a.aiChatDiag != nil { + a.aiChat.SetDiagnostics(newAppAIChatProgressDiagnostics(ctx, a.aiChatDiag, a.store)) + } + aiTester := func(cfg aiconfig.AIConfig) aiconfig.TestResult { + return aiconfig.TestConnection(context.Background(), cfg) + } + assignDefaults := func(cfg aiconfig.AIConfig, result aiconfig.TestResult) { + if !result.Connected { + return + } + _, _ = a.store.AssignAIConfigIfUnset(cfg.ID) + } + aiconfig.StartMonitor(context.Background(), a.aiConfigStore, aiTester, 30*time.Minute, assignDefaults) + a.encryptExistingStores() + go a.ensureCLIInPath() // non-blocking: avoid stalling Wails startup on slow I/O + if len(a.launchArgs) > 0 { + a.handleLaunchArgs(a.launchArgs) + } +} + +func (a *App) shutdown(ctx context.Context) { + _ = ctx + a.stopEmbeddedDaemon() + if a.sessionTracker != nil { + if err := a.sessionTracker.Close(); err != nil { + a.logErrorf("source=session event=session_close_failed error=%s", logField(err.Error())) + } + } +} diff --git a/app_ai.go b/app_ai.go new file mode 100644 index 0000000..5fb8ec2 --- /dev/null +++ b/app_ai.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "futrixdata/platform/internal/ai" + "futrixdata/platform/internal/aiconfig" + "futrixdata/platform/internal/datasource" +) + +func buildAIConfig(cfg Config) ai.Config { + baseURL := firstNonEmpty(cfg.AIBaseURL, os.Getenv("FUTRIX_AI_BASE_URL")) + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + apiKey := firstNonEmpty(cfg.AIAPIKey, os.Getenv("FUTRIX_AI_API_KEY"), os.Getenv("OPENAI_API_KEY")) + model := firstNonEmpty(cfg.AIModel, os.Getenv("FUTRIX_AI_MODEL")) + if model == "" { + model = "gpt-5.2" + } + timeout := time.Duration(cfg.AITimeoutSeconds) * time.Second + return ai.Config{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + Timeout: timeout, + } +} + +func (a *App) AssistMongo(req ai.MongoRequest) (ai.MongoAIResponse, error) { + if strings.TrimSpace(req.DatasourceID) == "" { + return ai.MongoAIResponse{}, errors.New("datasourceId is required") + } + ds, ok := a.store.Get(req.DatasourceID) + if !ok { + return ai.MongoAIResponse{}, errors.New("datasource not found") + } + if ds.Type != datasource.TypeMongoDB { + return ai.MongoAIResponse{}, errors.New("ai mongo assistant only supports mongodb") + } + client := a.getClientForDatasource(ds) + if client == nil || !client.Configured() { + return ai.MongoAIResponse{}, errors.New("ai provider not configured") + } + if req.Database == "" { + req.Database = ds.Database + } + return client.AssistMongo(context.Background(), ai.MongoAIRequest{ + Action: req.Action, + Statement: req.Statement, + Error: req.Error, + Prompt: req.Prompt, + Collection: req.Collection, + Database: req.Database, + Fields: req.Fields, + Indexes: req.Indexes, + }) +} + +func (a *App) getClientForDatasource(ds datasource.DataSource) *ai.Client { + if a.aiConfigStore != nil { + if selectedID := aiConfigIDFromOptions(ds.Options); selectedID != "" { + if cfg, ok := a.aiConfigStore.Get(selectedID); ok { + if client := a.buildClientFromConfig(cfg); client != nil { + return client + } + } + } + } + return a.getClient() +} + +func (a *App) getClient() *ai.Client { + if a.aiConfigStore != nil { + if cfg, ok := a.aiConfigStore.GetPreferred(); ok { + if client := a.buildClientFromConfig(cfg); client != nil { + return client + } + } + } + return a.fallbackAI +} + +func (a *App) buildClientFromConfig(cfg aiconfig.AIConfig) *ai.Client { + baseURL := cfg.BaseURL + if baseURL == "" { + if defaults, ok := aiconfig.ProviderDefaults[cfg.Provider]; ok { + baseURL = defaults.BaseURL + } + } + client := ai.NewClient(ai.Config{ + BaseURL: baseURL, + APIKey: cfg.APIKey, + Model: cfg.Model, + Timeout: 15 * time.Second, + }) + if !client.Configured() { + return nil + } + return client +} + +func aiConfigIDFromOptions(options map[string]any) string { + if options == nil { + return "" + } + raw, ok := options["aiConfigId"] + if !ok { + return "" + } + switch v := raw.(type) { + case string: + return strings.TrimSpace(v) + default: + return strings.TrimSpace(fmt.Sprint(v)) + } +} diff --git a/app_aichat.go b/app_aichat.go new file mode 100644 index 0000000..c1b893a --- /dev/null +++ b/app_aichat.go @@ -0,0 +1,752 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/aiconfig" + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/datasourcesecrets" + "futrixdata/platform/internal/planlimits" + "futrixdata/platform/internal/schemaprivacy" + "futrixdata/platform/internal/sensitivity" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const defaultAITimeout = 90 * time.Second + +func (a *App) AiChatTurn(req aichat.TurnRequest) (aichat.TurnResponse, error) { + if a.aiChat == nil { + return aichat.TurnResponse{}, errors.New("ai chat service not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.aiChat.Turn(ctx, req) +} + +func (a *App) AiChatTurnStream(req aichat.TurnRequest) (aichat.StreamStartResponse, error) { + if a.aiChat == nil { + return aichat.StreamStartResponse{}, errors.New("ai chat service not available") + } + if a.ctx == nil { + return aichat.StreamStartResponse{}, errors.New("app context not available") + } + if strings.TrimSpace(req.ConversationID) == "" { + return aichat.StreamStartResponse{}, errors.New("conversationId is required") + } + + streamID := fmt.Sprintf("stream_%x", time.Now().UTC().UnixNano()) + emitCtx := a.ctx + baseCtx := aichat.WithDiagnosticsContext(context.Background(), req.ConversationID, streamID) + streamCtx, cancel := context.WithCancel(baseCtx) + if a.aiChatStreams != nil { + a.aiChatStreams.register(streamID, cancel) + } + + go func() { + defer func() { + if a.aiChatStreams != nil { + a.aiChatStreams.unregister(streamID) + } + }() + + resp, err := a.aiChat.TurnStream(streamCtx, req, func(delta string) { + if delta == "" { + return + } + runtime.EventsEmit(emitCtx, "aichat:delta", map[string]any{ + "streamId": streamID, + "conversationId": req.ConversationID, + "delta": delta, + }) + }) + if err != nil { + if errors.Is(err, context.Canceled) { + runtime.EventsEmit(emitCtx, "aichat:cancelled", map[string]any{ + "streamId": streamID, + "conversationId": req.ConversationID, + }) + return + } + runtime.EventsEmit(emitCtx, "aichat:error", map[string]any{ + "streamId": streamID, + "conversationId": req.ConversationID, + "error": err.Error(), + }) + return + } + runtime.EventsEmit(emitCtx, "aichat:done", map[string]any{ + "streamId": streamID, + "conversationId": req.ConversationID, + "response": resp, + }) + }() + + return aichat.StreamStartResponse{StreamID: streamID}, nil +} + +func (a *App) AiChatCancelStream(streamID string) bool { + if a.aiChatStreams == nil { + return false + } + return a.aiChatStreams.cancel(strings.TrimSpace(streamID)) +} + +func (a *App) AiChatApprove(req aichat.ApproveRequest) (aichat.TurnResponse, error) { + if a.aiChat == nil { + return aichat.TurnResponse{}, errors.New("ai chat service not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.aiChat.Approve(ctx, req) +} + +type appAIChatModelResolver struct { + cfg Config + aiconfigStore *aiconfig.Store +} + +func newAppAIChatModelResolver(cfg Config, store *aiconfig.Store) aichat.ModelResolver { + return &appAIChatModelResolver{cfg: cfg, aiconfigStore: store} +} + +func (r *appAIChatModelResolver) Resolve(aiConfigID string) (aichat.Model, error) { + if r.aiconfigStore != nil { + if id := strings.TrimSpace(aiConfigID); id != "" { + cfg, ok := r.aiconfigStore.Get(id) + if !ok { + return nil, errors.New("ai configuration not found") + } + return modelFromAIConfig(cfg) + } + if cfg, ok := r.aiconfigStore.GetPreferred(); ok { + model, err := modelFromAIConfig(cfg) + if err == nil { + return model, nil + } + } + } + + fallback := buildAIConfig(r.cfg) + if strings.TrimSpace(fallback.APIKey) == "" { + return nil, errors.New("ai provider not configured") + } + return aichat.NewOpenAIEinoExtModel(aichat.OpenAICompatibleModelConfig{ + BaseURL: fallback.BaseURL, + APIKey: fallback.APIKey, + Model: fallback.Model, + Timeout: fallback.Timeout, + Referer: "http://localhost", + AppTitle: "FutrixData Platform", + }) +} + +func modelFromAIConfig(cfg aiconfig.AIConfig) (aichat.Model, error) { + baseURL := strings.TrimSpace(cfg.BaseURL) + if baseURL == "" { + if defaults, ok := aiconfig.ProviderDefaults[cfg.Provider]; ok { + baseURL = defaults.BaseURL + } + } + modelName := strings.TrimSpace(cfg.Model) + if modelName == "" { + if defaults, ok := aiconfig.ProviderDefaults[cfg.Provider]; ok { + modelName = defaults.DefaultModel + } + } + if strings.TrimSpace(cfg.APIKey) == "" || baseURL == "" || modelName == "" { + return nil, errors.New("ai provider not configured") + } + + maxTokens := aiChatMaxTokensFromOptions(cfg.Options) + + switch cfg.Provider { + case aiconfig.ProviderAnthropic: + return aichat.NewAnthropicModel(aichat.AnthropicModelConfig{ + BaseURL: baseURL, + APIKey: cfg.APIKey, + Model: modelName, + Timeout: defaultAITimeout, + MaxTokens: maxTokens, + }), nil + default: + return aichat.NewOpenAIEinoExtModel(aichat.OpenAICompatibleModelConfig{ + BaseURL: baseURL, + APIKey: cfg.APIKey, + Model: modelName, + Timeout: defaultAITimeout, + MaxTokens: maxTokens, + Referer: "http://localhost", + AppTitle: "FutrixData Platform", + }) + } +} + +func aiChatMaxTokensFromOptions(options map[string]any) int { + if options == nil { + return 0 + } + for _, key := range []string{"maxTokens", "maxCompletionTokens", "max_tokens", "max_completion_tokens"} { + raw, ok := options[key] + if !ok || raw == nil { + continue + } + switch v := raw.(type) { + case int: + if v > 0 { + return v + } + case int64: + if v > 0 { + return int(v) + } + case float64: + if v > 0 { + return int(v) + } + case float32: + if v > 0 { + return int(v) + } + default: + var parsed int + if _, err := fmt.Sscanf(strings.TrimSpace(fmt.Sprint(v)), "%d", &parsed); err == nil && parsed > 0 { + return parsed + } + } + } + return 0 +} + +// providerSummaryFunc returns the active chat provider/model identity in a +// shape suitable for audit recording. Implementations resolve from the +// AIConfig store; nil is acceptable and means "no resolver wired" — Gate then +// records the egress without provider metadata. +type providerSummaryFunc func(aiConfigID string) (provider, model, configID string) + +func providerSummaryFromResolver(store *aiconfig.Store) providerSummaryFunc { + if store == nil { + return nil + } + return func(aiConfigID string) (string, string, string) { + id := strings.TrimSpace(aiConfigID) + if id != "" { + if cfg, ok := store.Get(id); ok { + return string(cfg.Provider), cfg.Model, cfg.ID + } + } + if cfg, ok := store.GetPreferred(); ok { + return string(cfg.Provider), cfg.Model, cfg.ID + } + return "", "", "" + } +} + +type appAIChatTools struct { + store *datasource.Store + manager *console.Manager + redisDocs *console.RedisCommandDocsStore + schemaKB *schemaKnowledgeManager + masking *sensitivity.MaskingProcessor + authStore *auth.Store + schemaPrivacy *schemaprivacy.AuditStore + datasourceSecrets *datasourcesecrets.Manager + providerInfo providerSummaryFunc + datasourceTiming appDatasourceTimingStarter +} + +func newAppAIChatTools( + store *datasource.Store, + manager *console.Manager, + redisDocs *console.RedisCommandDocsStore, + schemaKB *schemaKnowledgeManager, + masking *sensitivity.MaskingProcessor, + authStore *auth.Store, + schemaPrivacy *schemaprivacy.AuditStore, + providerInfo providerSummaryFunc, + datasourceSecrets *datasourcesecrets.Manager, + datasourceTiming ...appDatasourceTimingStarter, +) aichat.Tools { + var datasourceTimingStarter appDatasourceTimingStarter + if len(datasourceTiming) > 0 { + datasourceTimingStarter = datasourceTiming[0] + } + return &appAIChatTools{ + store: store, + manager: manager, + redisDocs: redisDocs, + schemaKB: schemaKB, + masking: masking, + authStore: authStore, + schemaPrivacy: schemaPrivacy, + datasourceSecrets: datasourceSecrets, + providerInfo: providerInfo, + datasourceTiming: datasourceTimingStarter, + } +} + +// schemaPrivacyGate is the in-process consent + audit hop used by every AI +// Chat tool that returns schema metadata to the model. It looks up the +// active provider so the audit log can answer "where did this go?", then +// delegates to schemaprivacy.Gate which enforces the consent and writes the +// log entry. +// +// The active AI config ID is read off the context — the chat runtime stamps +// it onto every tool invocation so the audit reflects the provider this turn +// is actually using, not the user's preferred default. Falling back to "" is +// safe: providerInfo treats an empty ID as "use the preferred config". +func (t *appAIChatTools) schemaPrivacyGate(ctx context.Context, ds datasource.DataSource, trigger schemaprivacy.TriggerSource, summary schemaprivacy.SendSummary) error { + // Re-read the datasource snapshot from the store so consent changes + // that landed since the tool entered — in particular, revocations + // during a slow schema fetch — are honored at the moment the gate + // decides allow/deny. SensitivityScan handles the same race the same + // way; doing it here keeps the chat path symmetric. + if fresh, ok := t.store.Get(strings.TrimSpace(ds.ID)); ok { + ds = fresh + } + if t.providerInfo != nil { + provider, model, configID := t.providerInfo(schemaprivacy.AIConfigIDFromContext(ctx)) + summary.ProviderType = provider + summary.Model = model + summary.AIConfigID = configID + } + return schemaprivacy.Gate(t.schemaPrivacy, ds, trigger, summary) +} + +// schemaPrivacyPreflight runs the consent gate before the underlying schema +// fetch. Without this, a denied or unset datasource whose schema fetch +// errors out (missing cache, IO failure, adapter error) would surface as a +// generic backend error and skip the denied-egress audit row. The final +// schemaPrivacyGate call after a successful fetch is what records the +// allowed-egress row with real entity/field counts; this preflight only +// guarantees that refusals are enforced and audited up front. +// +// We re-read the datasource here before checking consent so a denied→allowed +// flip that lands between the caller's store.Get and this preflight does not +// trick us into entering the gate against a stale snapshot. Without the +// re-read, the inner schemaPrivacyGate would see fresh consent=allowed and +// write a phantom "allowed, 0 entities, 0 fields" row before the real +// post-fetch gate writes the audit row with proper counts. +func (t *appAIChatTools) schemaPrivacyPreflight(ctx context.Context, ds datasource.DataSource, trigger schemaprivacy.TriggerSource) error { + if fresh, ok := t.store.Get(strings.TrimSpace(ds.ID)); ok { + ds = fresh + } + if schemaprivacy.ConsentOf(ds) == schemaprivacy.ConsentAllowed { + return nil + } + return t.schemaPrivacyGate(ctx, ds, trigger, schemaprivacy.SendSummary{}) +} + +func (t *appAIChatTools) currentPlan() (string, bool) { + if t == nil || t.authStore == nil { + return "", false + } + state := t.authStore.Current() + if state.Session == nil { + return planlimits.EffectivePlanWithTrial("", "", 0, trialExpiresAt(state), time.Now()), true + } + license := state.Session.License + return planlimits.EffectivePlanWithTrial(license.Plan, license.Status, license.ExpiresAt, trialExpiresAt(state), time.Now()), true +} + +func (t *appAIChatTools) ensureDatasourceCreateAllowed() error { + check := t.datasourceCreateCheck() + if check == nil || t == nil || t.store == nil { + return nil + } + return check(len(t.store.List())) +} + +func (t *appAIChatTools) datasourceCreateCheck() func(count int) error { + plan, ok := t.currentPlan() + if !ok || t == nil || t.store == nil { + return nil + } + limit := planlimits.DatasourceLimit(plan) + if limit <= 0 { + return nil + } + return func(count int) error { + if count >= limit { + return errors.New(planlimits.DatasourceLimitError(plan)) + } + return nil + } +} + +func (t *appAIChatTools) ListDatasources(ctx context.Context) ([]aichat.DatasourceSummary, error) { + _ = ctx + items := t.store.List() + out := make([]aichat.DatasourceSummary, 0, len(items)) + for _, ds := range items { + out = append(out, aichat.DatasourceSummary{ + ID: ds.ID, + Name: ds.Name, + Type: string(ds.Type), + Host: ds.Host, + Port: ds.Port, + Database: ds.Database, + TrustLevel: string(ds.TrustLevel()), + Environment: ds.Environment(), + Dialect: ds.QueryDialect(), + }) + } + return out, nil +} + +func (t *appAIChatTools) GetDatasource(ctx context.Context, id string) (aichat.DatasourceSummary, error) { + _ = ctx + ds, ok := t.store.Get(id) + if !ok { + return aichat.DatasourceSummary{}, errors.New("datasource not found") + } + return aichat.DatasourceSummary{ + ID: ds.ID, + Name: ds.Name, + Type: string(ds.Type), + Host: ds.Host, + Port: ds.Port, + Database: ds.Database, + TrustLevel: string(ds.TrustLevel()), + Environment: ds.Environment(), + Dialect: ds.QueryDialect(), + }, nil +} + +func (t *appAIChatTools) CreateDatasource(ctx context.Context, input aichat.DatasourceCreateInput) (aichat.DatasourceSummary, error) { + _ = ctx + payload := DataSourcePayload{ + Name: input.Name, + Type: datasource.DataSourceType(strings.TrimSpace(input.Type)), + Host: input.Host, + Port: input.Port, + Username: input.Username, + Password: input.Password, + Database: input.Database, + AuthSource: input.AuthSource, + Options: input.Options, + } + if err := validateDataSourcePayload(payload); err != nil { + return aichat.DatasourceSummary{}, err + } + createCheck := t.datasourceCreateCheck() + created, err := t.store.CreateChecked(payload.toDataSource(""), func(input *datasource.DataSource, count int) error { + if createCheck == nil { + return t.externalizeDatasourceSecrets(ctx, input) + } + if err := createCheck(count); err != nil { + return err + } + return t.externalizeDatasourceSecrets(ctx, input) + }) + if err != nil { + return aichat.DatasourceSummary{}, err + } + return aichat.DatasourceSummary{ + ID: created.ID, + Name: created.Name, + Type: string(created.Type), + Host: created.Host, + Port: created.Port, + Database: created.Database, + TrustLevel: string(created.TrustLevel()), + Environment: created.Environment(), + Dialect: created.QueryDialect(), + }, nil +} + +func (t *appAIChatTools) externalizeDatasourceSecrets(ctx context.Context, ds *datasource.DataSource) error { + if t == nil || t.datasourceSecrets == nil || ds == nil { + return nil + } + next, err := t.datasourceSecrets.ExternalizeDatasourceSecrets(ctx, *ds) + if err != nil { + return err + } + *ds = next + return nil +} + +func (t *appAIChatTools) DeleteDatasource(ctx context.Context, datasourceID string) error { + _ = ctx + return t.store.Delete(strings.TrimSpace(datasourceID)) +} + +func (t *appAIChatTools) ListDatabases(ctx context.Context, datasourceID, pattern string) ([]string, error) { + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + return t.manager.ListDatabases(ctx, ds, console.ListOptions{Pattern: pattern}) +} + +func (t *appAIChatTools) ListEntities(ctx context.Context, datasourceID, pattern, database string) ([]string, error) { + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + if gateErr := t.schemaPrivacyPreflight(ctx, ds, schemaprivacy.TriggerAIChatListEntities); gateErr != nil { + return nil, gateErr + } + entities, err := t.manager.ListEntities(ctx, ds, console.ListOptions{Pattern: pattern}) + if err != nil { + return nil, err + } + if gateErr := t.schemaPrivacyGate(ctx, ds, schemaprivacy.TriggerAIChatListEntities, schemaprivacy.SendSummary{ + EntityCount: len(entities), + }); gateErr != nil { + return nil, gateErr + } + return entities, nil +} + +func (t *appAIChatTools) DescribeEntity(ctx context.Context, datasourceID, name, database string) (any, error) { + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + if gateErr := t.schemaPrivacyPreflight(ctx, ds, schemaprivacy.TriggerAIChatDescribeEntity); gateErr != nil { + return nil, gateErr + } + result, err := t.manager.DescribeEntity(ctx, ds, name) + if err != nil { + return nil, err + } + summary := schemaprivacy.SendSummary{EntityCount: 1} + summary.FieldCount, summary.IncludesComments = describeFieldStats(result) + if gateErr := t.schemaPrivacyGate(ctx, ds, schemaprivacy.TriggerAIChatDescribeEntity, summary); gateErr != nil { + return nil, gateErr + } + return result, nil +} + +// describeFieldStats reports the number of column-like fields and whether +// the result includes any free-text "details" (which adapters use for +// comments, partition info, etc — anything beyond name + type). It accepts +// the raw `any` return so callers don't depend on the console package's +// struct shape. +func describeFieldStats(result any) (int, bool) { + if result == nil { + return 0, false + } + switch typed := result.(type) { + case console.DescribeResult: + return len(typed.Columns), len(typed.Details) > 0 + case map[string]any: + fields := 0 + if cols, ok := typed["columns"].([]any); ok { + fields = len(cols) + } + hasDetails := false + if details, ok := typed["details"].([]any); ok && len(details) > 0 { + hasDetails = true + } + return fields, hasDetails + } + return 0, false +} + +func (t *appAIChatTools) ExplainStatement(ctx context.Context, datasourceID, statement, database string) (console.ExplainResult, error) { + ds, ok := t.store.Get(datasourceID) + if !ok { + return console.ExplainResult{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + statement = console.PrepareExplainStatement(statement, false, ds.Type) + return t.manager.Explain(ctx, ds, statement) +} + +func (t *appAIChatTools) ExecuteStatement(ctx context.Context, datasourceID, statement, database, pagingToken string, pageSize int, approved bool) (out aichat.QueryResult, err error) { + ds, ok := t.store.Get(datasourceID) + if !ok { + return aichat.QueryResult{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + execOpts := console.ExecuteOptions{ + PagingToken: pagingToken, + PageSize: pageSize, + } + finishTiming := func(error) {} + if t.datasourceTiming != nil { + ctx, finishTiming = t.datasourceTiming(ctx, "app.ai_chat.execute_statement", ds, statement, execOpts, approved) + defer func() { finishTiming(err) }() + } + var result console.QueryResult + if approved { + result, err = t.manager.ExecuteWithInteractiveApproval(ctx, ds, statement, execOpts) + } else { + result, err = t.manager.Execute(ctx, ds, statement, execOpts) + } + if err != nil { + return aichat.QueryResult{}, err + } + rawRows := cloneRows(result.Rows) + rawColumns := append([]string(nil), result.Columns...) + rawNextToken := result.NextToken + rawPrevToken := result.PrevToken + + sensitivity.ApplyQueryResultMasking(t.masking, ds.ID, &result) + maskedView := aichat.QueryResult{ + Columns: append([]string(nil), result.Columns...), + Rows: cloneRows(result.Rows), + RowCount: result.RowCount, + HasMore: result.HasMore, + NextToken: result.NextToken, + PrevToken: result.PrevToken, + ElapsedMs: result.ElapsedMs, + RequestedPageSize: result.RequestedPageSize, + EffectivePageSize: result.EffectivePageSize, + EffectiveLimitSource: result.EffectiveLimitSource, + Dialect: result.Dialect, + Environment: result.Environment, + } + return aichat.QueryResult{ + Columns: rawColumns, + Rows: rawRows, + RowCount: result.RowCount, + HasMore: result.HasMore, + NextToken: rawNextToken, + PrevToken: rawPrevToken, + ElapsedMs: result.ElapsedMs, + RequestedPageSize: result.RequestedPageSize, + EffectivePageSize: result.EffectivePageSize, + EffectiveLimitSource: result.EffectiveLimitSource, + Dialect: result.Dialect, + Environment: result.Environment, + AgentView: &maskedView, + }, nil +} + +func cloneRows(rows []map[string]any) []map[string]any { + if len(rows) == 0 { + return nil + } + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + if row == nil { + out = append(out, nil) + continue + } + cloned := make(map[string]any, len(row)) + for key, value := range row { + cloned[key] = cloneValue(value) + } + out = append(out, cloned) + } + return out +} + +func cloneValue(value any) any { + switch typed := value.(type) { + case map[string]any: + next := make(map[string]any, len(typed)) + for key, child := range typed { + next[key] = cloneValue(child) + } + return next + case []any: + next := make([]any, len(typed)) + for idx, child := range typed { + next[idx] = cloneValue(child) + } + return next + default: + return typed + } +} + +func (t *appAIChatTools) GetRedisCommandDocs(ctx context.Context, datasourceID, command string) (any, error) { + _ = command + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + adapter, err := t.manager.AdapterFor(ds.Type) + if err != nil { + return nil, err + } + redisAdapter, ok := adapter.(*console.RedisAdapter) + if !ok { + return nil, errors.New("redis adapter not available") + } + return t.redisDocs.Get(ctx, ds.ID, func(ctx context.Context) (map[string]any, error) { + // Resolve SecretRef-backed credentials; this adapter call bypasses the + // manager dispatch path that normally resolves secrets. + resolved, err := t.manager.ResolveDatasource(ctx, ds) + if err != nil { + return nil, err + } + return console.FetchRedisCommandDocs(ctx, redisAdapter, resolved) + }) +} + +func (t *appAIChatTools) GetSchemaKnowledge(ctx context.Context, datasourceID, entity, database string) (any, error) { + if t.schemaKB == nil { + return nil, errors.New("schema knowledge is not available") + } + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + if gateErr := t.schemaPrivacyPreflight(ctx, ds, schemaprivacy.TriggerAIChatGetSchemaKnowledge); gateErr != nil { + return nil, gateErr + } + result, err := t.schemaKB.GetSchemaKnowledge(ds, entity) + if err != nil { + return nil, err + } + summary := schemaprivacy.SendSummary{} + if count, ok := result["entityCount"].(int); ok { + summary.EntityCount = count + } + if entities, ok := result["entities"].([]schemaKnowledgeEntity); ok { + fields := 0 + for _, e := range entities { + fields += len(e.Columns) + if len(e.Details) > 0 { + summary.IncludesComments = true + } + } + summary.FieldCount = fields + } + if gateErr := t.schemaPrivacyGate(ctx, ds, schemaprivacy.TriggerAIChatGetSchemaKnowledge, summary); gateErr != nil { + return nil, gateErr + } + return result, nil +} + +func (t *appAIChatTools) GetERKnowledge(ctx context.Context, datasourceID, database string) (any, error) { + if t.schemaKB == nil { + return nil, errors.New("schema knowledge is not available") + } + ds, ok := t.store.Get(datasourceID) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + if gateErr := t.schemaPrivacyPreflight(ctx, ds, schemaprivacy.TriggerAIChatGetERKnowledge); gateErr != nil { + return nil, gateErr + } + result, err := t.schemaKB.GetERKnowledge(ds) + if err != nil { + return nil, err + } + if gateErr := t.schemaPrivacyGate(ctx, ds, schemaprivacy.TriggerAIChatGetERKnowledge, schemaprivacy.SendSummary{}); gateErr != nil { + return nil, gateErr + } + return result, nil +} diff --git a/app_aichat_progress_diagnostics.go b/app_aichat_progress_diagnostics.go new file mode 100644 index 0000000..9b68f98 --- /dev/null +++ b/app_aichat_progress_diagnostics.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/datasource" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +type appAIChatProgressDiagnostics struct { + emitCtx context.Context + next aichat.Diagnostics + store *datasource.Store +} + +func newAppAIChatProgressDiagnostics(emitCtx context.Context, next aichat.Diagnostics, store *datasource.Store) aichat.Diagnostics { + return &appAIChatProgressDiagnostics{ + emitCtx: emitCtx, + next: next, + store: store, + } +} + +func (d *appAIChatProgressDiagnostics) Log(event string, fields map[string]any) { + if d == nil { + return + } + if d.next != nil { + d.next.Log(event, fields) + } + if d.emitCtx == nil { + return + } + if fields == nil { + return + } + + streamID, _ := fields["streamId"].(string) + streamID = strings.TrimSpace(streamID) + if streamID == "" { + return + } + conversationID, _ := fields["conversationId"].(string) + + message := d.progressMessageForEvent(event, fields) + if message == "" { + return + } + + if d.next != nil { + d.next.Log("ui_progress_emit", map[string]any{ + "streamId": streamID, + "conversationId": conversationID, + "sourceEvent": event, + "message": message, + }) + } + + payload := map[string]any{ + "streamId": streamID, + "conversationId": conversationID, + "message": message, + "event": event, + } + if ms, ok := fields["durationMs"]; ok { + payload["durationMs"] = ms + } + runtime.EventsEmit(d.emitCtx, "aichat:progress", payload) +} + +func (d *appAIChatProgressDiagnostics) progressMessageForEvent(event string, fields map[string]any) string { + fieldString := func(key string) string { + if fields == nil { + return "" + } + if v, ok := fields[key].(string); ok { + return strings.TrimSpace(v) + } + return strings.TrimSpace(fmt.Sprint(fields[key])) + } + fieldInt := func(key string) int { + if fields == nil { + return 0 + } + switch v := fields[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + default: + var parsed int + _, _ = fmt.Sscanf(strings.TrimSpace(fmt.Sprint(v)), "%d", &parsed) + return parsed + } + } + + datasourceLabel := func(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + if d == nil || d.store == nil { + return id + } + ds, ok := d.store.Get(id) + if !ok { + return id + } + name := strings.TrimSpace(ds.Name) + if name == "" { + return id + } + return name + } + + switch event { + case "turn_start": + routeName := fieldString("routeName") + currentDatasource := datasourceLabel(fieldString("currentDatasource")) + messageCount := fieldInt("messageCount") + parts := make([]string, 0, 3) + if routeName != "" { + parts = append(parts, "route: "+routeName) + } + if currentDatasource != "" { + parts = append(parts, "datasource: "+currentDatasource) + } + if messageCount > 0 { + parts = append(parts, fmt.Sprintf("messages: %d", messageCount)) + } + if len(parts) > 0 { + return fmt.Sprintf("Thinking (%s)…", strings.Join(parts, ", ")) + } + return "Thinking…" + case "model_stream_first_delta": + return "Model is responding…" + case "model_output_parsed": + toolCallsCount := fieldInt("toolCallsCount") + assistantLen := fieldInt("assistantLen") + if toolCallsCount > 0 && assistantLen == 0 { + preview := fieldString("toolCallsPreview") + if preview != "" && preview != "" { + return fmt.Sprintf("Planning next actions (%s)…", preview) + } + return fmt.Sprintf("Planning next actions (%d tools)…", toolCallsCount) + } + return "" + case "tool_protocol_force_toolcalls_triggered": + return "Refining plan…" + + case "tool_list_entities_start": + database := fieldString("database") + datasourceID := datasourceLabel(fieldString("datasourceId")) + if database != "" && datasourceID != "" { + return fmt.Sprintf("Listing entities (db: %s, datasource: %s)…", database, datasourceID) + } + if database != "" { + return fmt.Sprintf("Listing entities (db: %s)…", database) + } + if datasourceID != "" { + return fmt.Sprintf("Listing entities (datasource: %s)…", datasourceID) + } + return "Listing entities…" + case "tool_describe_entity_start": + name := fieldString("name") + database := fieldString("database") + datasourceID := datasourceLabel(fieldString("datasourceId")) + parts := make([]string, 0, 3) + if name != "" { + parts = append(parts, name) + } + if database != "" { + parts = append(parts, "db: "+database) + } + if datasourceID != "" { + parts = append(parts, "datasource: "+datasourceID) + } + if len(parts) > 0 { + return fmt.Sprintf("Describing entity (%s)…", strings.Join(parts, ", ")) + } + return "Describing entity…" + case "tool_explain_statement_start": + database := fieldString("database") + datasourceID := datasourceLabel(fieldString("datasourceId")) + statementLen := fieldInt("statementLen") + parts := make([]string, 0, 3) + if statementLen > 0 { + parts = append(parts, fmt.Sprintf("len: %d", statementLen)) + } + if database != "" { + parts = append(parts, "db: "+database) + } + if datasourceID != "" { + parts = append(parts, "datasource: "+datasourceID) + } + if len(parts) > 0 { + return fmt.Sprintf("Explaining statement (%s)…", strings.Join(parts, ", ")) + } + return "Explaining statement…" + case "tool_execute_statement_start": + database := fieldString("database") + datasourceID := datasourceLabel(fieldString("datasourceId")) + statementLen := fieldInt("statementLen") + parts := make([]string, 0, 3) + if statementLen > 0 { + parts = append(parts, fmt.Sprintf("len: %d", statementLen)) + } + if database != "" { + parts = append(parts, "db: "+database) + } + if datasourceID != "" { + parts = append(parts, "datasource: "+datasourceID) + } + if len(parts) > 0 { + return fmt.Sprintf("Executing statement (%s)…", strings.Join(parts, ", ")) + } + return "Executing statement…" + case "approval_pending": + kind := fieldString("kind") + if kind != "" { + return fmt.Sprintf("Waiting for approval (%s)…", kind) + } + return "Waiting for approval…" + } + + return "" +} diff --git a/app_aichat_stream_registry.go b/app_aichat_stream_registry.go new file mode 100644 index 0000000..17e58b0 --- /dev/null +++ b/app_aichat_stream_registry.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "sync" +) + +type aiChatStreamRegistry struct { + mu sync.Mutex + streams map[string]context.CancelFunc +} + +func newAIChatStreamRegistry() *aiChatStreamRegistry { + return &aiChatStreamRegistry{streams: make(map[string]context.CancelFunc)} +} + +func (r *aiChatStreamRegistry) register(streamID string, cancel context.CancelFunc) { + if r == nil || streamID == "" || cancel == nil { + return + } + r.mu.Lock() + r.streams[streamID] = cancel + r.mu.Unlock() +} + +func (r *aiChatStreamRegistry) unregister(streamID string) { + if r == nil || streamID == "" { + return + } + r.mu.Lock() + delete(r.streams, streamID) + r.mu.Unlock() +} + +func (r *aiChatStreamRegistry) cancel(streamID string) bool { + if r == nil || streamID == "" { + return false + } + r.mu.Lock() + cancel := r.streams[streamID] + delete(r.streams, streamID) + r.mu.Unlock() + if cancel == nil { + return false + } + cancel() + return true +} diff --git a/app_aichat_test.go b/app_aichat_test.go new file mode 100644 index 0000000..12ed73f --- /dev/null +++ b/app_aichat_test.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/riskengine" + "futrixdata/platform/internal/sensitivity" +) + +type appExecuteAdapterStub struct { + result console.QueryResult +} + +func (a appExecuteAdapterStub) TestConnection(context.Context, datasource.DataSource) error { + return nil +} + +func (a appExecuteAdapterStub) ListEntities(context.Context, datasource.DataSource, console.ListOptions) ([]string, error) { + return nil, nil +} + +func (a appExecuteAdapterStub) DescribeEntity(context.Context, datasource.DataSource, string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil +} + +func (a appExecuteAdapterStub) Execute(context.Context, datasource.DataSource, string, console.ExecuteOptions) (console.QueryResult, error) { + return a.result, nil +} + +func (a appExecuteAdapterStub) Explain(context.Context, datasource.DataSource, string) (console.ExplainResult, error) { + return console.ExplainResult{}, nil +} + +func TestAppAIChatTools_ExecuteStatement_KeepsGuiResultRawAndMasksAgentView(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + created, err := dsStore.Create(datasource.DataSource{ + Name: "Users MySQL", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + sensitivityStore := sensitivity.NewStore(filepath.Join(root, "sensitivity.json")) + if err := sensitivityStore.SetDatasource(sensitivity.DatasourceClassification{ + DatasourceID: created.ID, + DatasourceName: created.Name, + DatasourceType: string(created.Type), + Entities: map[string]sensitivity.EntityClassification{ + "users": { + Fields: map[string]sensitivity.FieldClassification{ + "email": {Level: "L4", Category: sensitivity.CategoryPII, Source: sensitivity.SourceManual}, + }, + }, + }, + }); err != nil { + t.Fatalf("set sensitivity datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(created.Type, appExecuteAdapterStub{ + result: console.QueryResult{ + Columns: []string{"email"}, + Rows: []map[string]any{{"email": "user@example.com"}}, + RowCount: 1, + SourceEntity: "users", + }, + }) + + masking := sensitivity.NewMaskingProcessor(sensitivityStore, []byte("test-local-masking-secret-32bytes")) + tools := newAppAIChatTools(dsStore, manager, nil, nil, masking, nil, nil, nil, nil) + + result, err := tools.ExecuteStatement(context.Background(), created.ID, "SELECT email FROM users", "", "", 100, true) + if err != nil { + t.Fatalf("ExecuteStatement: %v", err) + } + got, _ := result.Rows[0]["email"].(string) + if got != "user@example.com" { + t.Fatalf("expected gui-facing ai chat result to stay raw, got %q", got) + } + if result.AgentView == nil { + t.Fatal("expected masked agent view to be attached") + } + masked, _ := result.AgentView.Rows[0]["email"].(string) + if !strings.HasPrefix(masked, "masked:") { + t.Fatalf("expected masked agent-view value, got %q", masked) + } +} + +func TestAppAIChatTools_ExecuteStatement_MasksAgentViewWhenSQLMetadataIsIncomplete(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + created, err := dsStore.Create(datasource.DataSource{ + Name: "Contacts MySQL", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + sensitivityStore := sensitivity.NewStore(filepath.Join(root, "sensitivity.json")) + if err := sensitivityStore.SetDatasource(sensitivity.DatasourceClassification{ + DatasourceID: created.ID, + DatasourceName: created.Name, + DatasourceType: string(created.Type), + Entities: map[string]sensitivity.EntityClassification{ + "fd_crm_contact": { + Fields: map[string]sensitivity.FieldClassification{ + "email": {Level: "L4", Category: sensitivity.CategoryContact, Source: sensitivity.SourceManual}, + "phone": {Level: "L4", Category: sensitivity.CategoryContact, Source: sensitivity.SourceManual}, + }, + }, + }, + }); err != nil { + t.Fatalf("set sensitivity datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(created.Type, appExecuteAdapterStub{ + result: console.QueryResult{ + Columns: []string{"contact_id", "email", "phone"}, + Rows: []map[string]any{{ + "contact_id": "1", + "email": "contact1@futrix.test", + "phone": "+1408000001", + }}, + ColumnMeta: []console.ResultColumn{ + {Key: "contact_id", Name: "contact_id", Position: 0}, + {Key: "email", Name: "email", Position: 1}, + {Key: "phone", Name: "phone", Position: 2}, + }, + RowValues: [][]any{{"1", "contact1@futrix.test", "+1408000001"}}, + RowCount: 1, + }, + }) + + masking := sensitivity.NewMaskingProcessor(sensitivityStore, []byte("test-local-masking-secret-32bytes")) + tools := newAppAIChatTools(dsStore, manager, nil, nil, masking, nil, nil, nil, nil) + + result, err := tools.ExecuteStatement(context.Background(), created.ID, "SELECT contact_id, email, phone FROM fd_crm_contact", "", "", 100, true) + if err != nil { + t.Fatalf("ExecuteStatement: %v", err) + } + if got, _ := result.Rows[0]["email"].(string); got != "contact1@futrix.test" { + t.Fatalf("expected gui-facing email to stay raw, got %q", got) + } + if got, _ := result.Rows[0]["phone"].(string); got != "+1408000001" { + t.Fatalf("expected gui-facing phone to stay raw, got %q", got) + } + if result.AgentView == nil { + t.Fatal("expected masked agent view to be attached") + } + if got, _ := result.AgentView.Rows[0]["email"].(string); !strings.HasPrefix(got, "masked:") { + t.Fatalf("expected masked agent-view email, got %q", got) + } + if got, _ := result.AgentView.Rows[0]["phone"].(string); !strings.HasPrefix(got, "masked:") { + t.Fatalf("expected masked agent-view phone, got %q", got) + } +} + +func TestApp_ExecuteStatement_LeavesHumanConsoleUnmasked(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + created, err := dsStore.Create(datasource.DataSource{ + Name: "Users MySQL", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(created.Type, appExecuteAdapterStub{ + result: console.QueryResult{ + Columns: []string{"email"}, + Rows: []map[string]any{{"email": "user@example.com"}}, + RowCount: 1, + SourceEntity: "users", + }, + }) + + app := &App{ + store: dsStore, + manager: manager, + } + + result, err := app.ExecuteStatement(created.ID, "SELECT email FROM users", "", "", 100, "", true, 0, 0, 0) + if err != nil { + t.Fatalf("ExecuteStatement: %v", err) + } + got, _ := result.Rows[0]["email"].(string) + if got != "user@example.com" { + t.Fatalf("expected human console result to stay raw, got %q", got) + } +} + +func TestAppAIChatTools_CreateDatasource_RespectsFreePlanLimit(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + for i := 0; i < 3; i++ { + if _, err := dsStore.Create(datasource.DataSource{ + Name: "Seed", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + tools := newAppAIChatTools( + dsStore, + console.NewManager(), + nil, + nil, + nil, + newAuthStoreWithPlan(t, "free"), + nil, + nil, + nil, + ) + + _, err := tools.CreateDatasource(context.Background(), aichat.DatasourceCreateInput{ + Name: "Blocked", + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }) + if err == nil { + t.Fatalf("expected ai chat datasource creation to be blocked for free") + } + if got := err.Error(); got != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %q", got) + } +} + +func TestAppAIChatTools_CreateDatasource_RespectsLoggedOutFreeLimit(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + for i := 0; i < 3; i++ { + if _, err := dsStore.Create(datasource.DataSource{ + Name: "Seed", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + authStore := auth.NewStore(filepath.Join(root, "auth-session.json")) + if err := authStore.Load(); err != nil { + t.Fatalf("load auth store: %v", err) + } + state := authStore.Current() + state.Trial = expiredLocalTrial() + if err := authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + tools := newAppAIChatTools( + dsStore, + console.NewManager(), + nil, + nil, + nil, + authStore, + nil, + nil, + nil, + ) + + _, err := tools.CreateDatasource(context.Background(), aichat.DatasourceCreateInput{ + Name: "Blocked", + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }) + if err == nil { + t.Fatalf("expected ai chat datasource creation to be blocked for logged-out free use") + } + if got := err.Error(); got != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %q", got) + } +} + +func TestAppAIChatTools_CreateDatasource_AllowsLoggedOutActiveTrialBeyondFreeLimit(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + for i := 0; i < 3; i++ { + if _, err := dsStore.Create(datasource.DataSource{ + Name: "Seed", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + authStore := auth.NewStore(filepath.Join(root, "auth-session.json")) + if err := authStore.Load(); err != nil { + t.Fatalf("load auth store: %v", err) + } + state := authStore.Current() + state.Trial = activeLocalTrial() + if err := authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + tools := newAppAIChatTools( + dsStore, + console.NewManager(), + nil, + nil, + nil, + authStore, + nil, + nil, + nil, + ) + + created, err := tools.CreateDatasource(context.Background(), aichat.DatasourceCreateInput{ + Name: "Trial", + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + if created.Name != "Trial" { + t.Fatalf("expected trial datasource to be created, got %#v", created) + } +} + +func TestAppAIChatTools_ApprovedFlagUsesInteractiveApprovalPath(t *testing.T) { + root := t.TempDir() + dsStore := datasource.NewStore(filepath.Join(root, "datasources.json")) + created, err := dsStore.Create(datasource.DataSource{ + Name: "Users MySQL", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(created.Type, appExecuteAdapterStub{}) + manager.SetInterceptor(riskengine.NewGuard(riskengine.NewEngine())) + + tools := newAppAIChatTools(dsStore, manager, nil, nil, nil, nil, nil, nil, nil) + + if _, err := tools.ExecuteStatement(context.Background(), created.ID, "DELETE FROM users", "", "", 100, true); err != nil { + t.Fatalf("expected approved AI chat execution to proceed, got: %v", err) + } +} diff --git a/app_aiconfig.go b/app_aiconfig.go new file mode 100644 index 0000000..d391daf --- /dev/null +++ b/app_aiconfig.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "errors" + "strings" + + "futrixdata/platform/internal/aiconfig" +) + +// --- Embedding config bindings --- + +func (a *App) ListEmbeddingConfigs() ([]aiconfig.AIConfig, error) { + configs := a.aiConfigStore.ListByPurpose(aiconfig.PurposeEmbedding) + masked := make([]aiconfig.AIConfig, len(configs)) + for i, cfg := range configs { + masked[i] = maskAIKey(cfg) + } + return masked, nil +} + +func (a *App) CreateEmbeddingConfig(payload AIConfigPayload) (aiconfig.AIConfig, error) { + if strings.TrimSpace(payload.Name) == "" { + return aiconfig.AIConfig{}, errors.New("name is required") + } + if strings.TrimSpace(payload.Model) == "" { + return aiconfig.AIConfig{}, errors.New("model is required") + } + cfg := payload.toAIConfig("") + cfg.Purpose = aiconfig.PurposeEmbedding + created, err := a.aiConfigStore.Create(cfg) + if err != nil { + return aiconfig.AIConfig{}, err + } + return maskAIKey(created), nil +} + +func (a *App) UpdateEmbeddingConfig(id string, payload AIConfigPayload) (aiconfig.AIConfig, error) { + existing, ok := a.aiConfigStore.Get(id) + if !ok { + return aiconfig.AIConfig{}, errors.New("configuration not found") + } + if strings.TrimSpace(payload.APIKey) == "" || isMaskedAIKey(payload.APIKey, existing.APIKey) { + payload.APIKey = "" + nextProvider := existing.Provider + if strings.TrimSpace(string(payload.Provider)) != "" { + nextProvider = payload.Provider + } + nextBaseURL := existing.BaseURL + if strings.TrimSpace(payload.BaseURL) != "" { + nextBaseURL = strings.TrimSpace(payload.BaseURL) + } + if nextProvider == existing.Provider && strings.TrimSpace(nextBaseURL) == strings.TrimSpace(existing.BaseURL) { + payload.APIKey = existing.APIKey + } + } + cfg := payload.toAIConfig(id) + cfg.Purpose = aiconfig.PurposeEmbedding + updated, err := a.aiConfigStore.Update(id, cfg) + if err != nil { + return aiconfig.AIConfig{}, err + } + return maskAIKey(updated), nil +} + +func (a *App) DeleteEmbeddingConfig(id string) (bool, error) { + return a.DeleteAIConfig(id) +} + +func (a *App) ListEmbeddingProviders() (map[string]aiconfig.ProviderInfo, error) { + providers := make(map[string]aiconfig.ProviderInfo) + for key, value := range aiconfig.EmbeddingProviderDefaults { + providers[string(key)] = value + } + return providers, nil +} + +func (a *App) TestEmbeddingConfig(id string) (aiconfig.TestResult, error) { + cfg, ok := a.aiConfigStore.Get(id) + if !ok { + return aiconfig.TestResult{}, errors.New("configuration not found") + } + result := aiconfig.TestEmbeddingConnection(context.Background(), cfg) + if !result.Connected { + msg := strings.TrimSpace(result.Error) + if msg == "" { + msg = "connection failed" + } + return result, errors.New(msg) + } + _, _ = a.aiConfigStore.UpdateStatus(id, result) + return result, nil +} + +func (a *App) TestEmbeddingConfigPayload(payload AIConfigPayload) (aiconfig.TestResult, error) { + if strings.TrimSpace(payload.Model) == "" { + return aiconfig.TestResult{}, errors.New("model is required") + } + cfg := payload.toAIConfig("") + cfg.Purpose = aiconfig.PurposeEmbedding + result := aiconfig.TestEmbeddingConnection(context.Background(), cfg) + if !result.Connected { + msg := strings.TrimSpace(result.Error) + if msg == "" { + msg = "connection failed" + } + return result, errors.New(msg) + } + return result, nil +} + +// ComputeEmbeddingForSearch converts text to a vector using a configured +// embedding provider. Called by the ChromaDB console for text-based search. +// The dimensions parameter controls the output vector size (0 = model default). +func (a *App) ComputeEmbeddingForSearch(embeddingConfigID, text string, dimensions int) ([]float64, error) { + cfg, ok := a.aiConfigStore.Get(embeddingConfigID) + if !ok { + return nil, errors.New("embedding configuration not found") + } + return aiconfig.ComputeEmbedding(context.Background(), cfg, text, dimensions) +} + +type AIConfigPayload struct { + Name string `json:"name"` + Provider aiconfig.ProviderType `json:"provider"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Model string `json:"model"` + Options map[string]any `json:"options"` +} + +func (p AIConfigPayload) toAIConfig(id string) aiconfig.AIConfig { + return aiconfig.AIConfig{ + ID: id, + Name: strings.TrimSpace(p.Name), + Provider: p.Provider, + BaseURL: strings.TrimSpace(p.BaseURL), + APIKey: p.APIKey, + Model: strings.TrimSpace(p.Model), + Options: p.Options, + } +} + +func validateAIConfigPayload(p AIConfigPayload) error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("name is required") + } + if p.Provider == "" { + return errors.New("provider is required") + } + switch p.Provider { + case aiconfig.ProviderOpenAI, aiconfig.ProviderAnthropic, aiconfig.ProviderGemini, + aiconfig.ProviderQwen, aiconfig.ProviderZhipu, aiconfig.ProviderDeepSeek, + aiconfig.ProviderOpenRouter, aiconfig.ProviderOllama, aiconfig.ProviderLMStudio, + aiconfig.ProviderCustom: + default: + return errors.New("unsupported provider") + } + if strings.TrimSpace(p.APIKey) == "" { + return errors.New("apiKey is required") + } + if p.Provider == aiconfig.ProviderCustom && strings.TrimSpace(p.BaseURL) == "" { + return errors.New("baseUrl is required for custom provider") + } + return nil +} + +func validateAITestPayload(p AIConfigPayload) error { + if err := validateAIConfigPayload(p); err != nil { + return err + } + if strings.TrimSpace(p.Model) == "" { + return errors.New("model is required") + } + return nil +} + +func maskAIKey(cfg aiconfig.AIConfig) aiconfig.AIConfig { + if len(cfg.APIKey) > 8 { + cfg.APIKey = cfg.APIKey[:4] + "***" + cfg.APIKey[len(cfg.APIKey)-4:] + } else if len(cfg.APIKey) > 0 { + cfg.APIKey = "***" + } + return cfg +} + +func maskAIKeyString(apiKey string) string { + if len(apiKey) > 8 { + return apiKey[:4] + "***" + apiKey[len(apiKey)-4:] + } + if len(apiKey) > 0 { + return "***" + } + return "" +} + +func isMaskedAIKey(apiKey, existingKey string) bool { + trimmed := strings.TrimSpace(apiKey) + if trimmed == "" { + return false + } + if strings.HasPrefix(trimmed, "***") { + return true + } + return trimmed == maskAIKeyString(existingKey) +} + +func (a *App) ListAIConfigs() ([]aiconfig.AIConfig, error) { + configs := a.aiConfigStore.ListByPurpose(aiconfig.PurposeChat) + masked := make([]aiconfig.AIConfig, len(configs)) + for i, cfg := range configs { + masked[i] = maskAIKey(cfg) + } + return masked, nil +} + +func (a *App) CreateAIConfig(payload AIConfigPayload) (aiconfig.AIConfig, error) { + if err := validateAIConfigPayload(payload); err != nil { + return aiconfig.AIConfig{}, err + } + created, err := a.aiConfigStore.Create(payload.toAIConfig("")) + if err != nil { + return aiconfig.AIConfig{}, err + } + return maskAIKey(created), nil +} + +func (a *App) UpdateAIConfig(id string, payload AIConfigPayload) (aiconfig.AIConfig, error) { + existing, ok := a.aiConfigStore.Get(id) + if !ok { + return aiconfig.AIConfig{}, errors.New("configuration not found") + } + if strings.TrimSpace(string(payload.Provider)) == "" { + payload.Provider = existing.Provider + } + if strings.TrimSpace(payload.APIKey) == "" || isMaskedAIKey(payload.APIKey, existing.APIKey) { + if payload.Provider == existing.Provider { + payload.APIKey = existing.APIKey + } + } + if err := validateAIConfigPayload(payload); err != nil { + return aiconfig.AIConfig{}, err + } + updated, err := a.aiConfigStore.Update(id, payload.toAIConfig(id)) + if err != nil { + return aiconfig.AIConfig{}, err + } + return maskAIKey(updated), nil +} + +func (a *App) DeleteAIConfig(id string) (bool, error) { + if err := a.aiConfigStore.Delete(id); err != nil { + return false, err + } + return true, nil +} + +func (a *App) GetAIConfigAPIKey(id string) (string, error) { + cfg, ok := a.aiConfigStore.Get(id) + if !ok { + return "", errors.New("configuration not found") + } + return cfg.APIKey, nil +} + +func (a *App) ListAIProviders() (map[string]aiconfig.ProviderInfo, error) { + providers := make(map[string]aiconfig.ProviderInfo) + for key, value := range aiconfig.ProviderDefaults { + providers[string(key)] = value + } + return providers, nil +} + +func (a *App) TestAIConfig(id string) (aiconfig.TestResult, error) { + cfg, ok := a.aiConfigStore.Get(id) + if !ok { + return aiconfig.TestResult{}, errors.New("configuration not found") + } + result := aiconfig.TestConnection(context.Background(), cfg) + if !result.Connected { + message := strings.TrimSpace(result.Error) + if message == "" { + message = "connection failed" + } + return result, errors.New(message) + } + updated, err := a.aiConfigStore.UpdateStatus(id, result) + if err == nil { + _, _ = a.store.AssignAIConfigIfUnset(updated.ID) + } + return result, nil +} + +func (a *App) TestAIConfigPayload(payload AIConfigPayload) (aiconfig.TestResult, error) { + if err := validateAITestPayload(payload); err != nil { + return aiconfig.TestResult{}, err + } + result := aiconfig.TestConnection(context.Background(), payload.toAIConfig("")) + if !result.Connected { + message := strings.TrimSpace(result.Error) + if message == "" { + message = "connection failed" + } + return result, errors.New(message) + } + return result, nil +} + +func (a *App) TestAIConfigPreview(id string, payload AIConfigPayload) (aiconfig.TestResult, error) { + cfg, ok := a.aiConfigStore.Get(id) + if !ok { + return aiconfig.TestResult{}, errors.New("configuration not found") + } + if payload.Provider != "" { + cfg.Provider = payload.Provider + if strings.TrimSpace(payload.BaseURL) == "" { + cfg.BaseURL = "" + } + } + if strings.TrimSpace(payload.BaseURL) != "" { + cfg.BaseURL = strings.TrimSpace(payload.BaseURL) + } + if strings.TrimSpace(payload.Model) != "" { + cfg.Model = strings.TrimSpace(payload.Model) + } + apiKey := strings.TrimSpace(payload.APIKey) + if apiKey != "" && !isMaskedAIKey(apiKey, cfg.APIKey) { + cfg.APIKey = payload.APIKey + } + + result := aiconfig.TestConnection(context.Background(), cfg) + if !result.Connected { + message := strings.TrimSpace(result.Error) + if message == "" { + message = "connection failed" + } + return result, errors.New(message) + } + return result, nil +} diff --git a/app_aiconfig_test.go b/app_aiconfig_test.go new file mode 100644 index 0000000..79b50d3 --- /dev/null +++ b/app_aiconfig_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "futrixdata/platform/internal/aiconfig" +) + +func TestUpdateAIConfig_PreservesExistingKeyWhenPayloadIsMasked(t *testing.T) { + dir := t.TempDir() + store := aiconfig.NewStore(filepath.Join(dir, "aiconfigs.json")) + + created, err := store.Create(aiconfig.AIConfig{ + Name: "Test Config", + Provider: aiconfig.ProviderCustom, + BaseURL: "http://localhost:9999/v1", + APIKey: "sk-real-1234", + Model: "test-model", + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + app := &App{aiConfigStore: store} + masked := maskAIKey(created).APIKey + + _, err = app.UpdateAIConfig(created.ID, AIConfigPayload{ + Name: created.Name, + Provider: created.Provider, + BaseURL: created.BaseURL, + APIKey: masked, + Model: created.Model, + }) + if err != nil { + t.Fatalf("update config: %v", err) + } + + updated, ok := store.Get(created.ID) + if !ok { + t.Fatalf("updated config not found") + } + if updated.APIKey != created.APIKey { + t.Fatalf("expected stored api key to remain %q, got %q", created.APIKey, updated.APIKey) + } +} + +func TestUpdateEmbeddingConfig_DoesNotPreserveExistingKeyWhenEndpointChanges(t *testing.T) { + dir := t.TempDir() + store := aiconfig.NewStore(filepath.Join(dir, "aiconfigs.json")) + + created, err := store.Create(aiconfig.AIConfig{ + Name: "Embedding Config", + Provider: aiconfig.ProviderOpenAI, + BaseURL: "https://api.openai.com/v1", + APIKey: "sk-real-1234", + Model: "text-embedding-3-small", + Purpose: aiconfig.PurposeEmbedding, + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + app := &App{aiConfigStore: store} + masked := maskAIKey(created).APIKey + + _, err = app.UpdateEmbeddingConfig(created.ID, AIConfigPayload{ + Name: created.Name, + Provider: aiconfig.ProviderCustom, + BaseURL: "https://embeddings.example.com/v1", + APIKey: masked, + Model: "text-embedding-3-small", + }) + if err != nil { + t.Fatalf("update embedding config: %v", err) + } + + updated, ok := store.Get(created.ID) + if !ok { + t.Fatalf("updated config not found") + } + if updated.APIKey != "" { + t.Fatalf("expected stored api key to be cleared after endpoint change, got %q", updated.APIKey) + } +} + +func TestTestAIConfigPreview_UsesStoredKeyWhenPayloadIsMasked(t *testing.T) { + const storedKey = "sk-real-1234" + const expectedAuth = "Bearer " + storedKey + + gotAuth := "" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + http.NotFound(w, r) + return + } + gotAuth = r.Header.Get("Authorization") + if gotAuth != expectedAuth { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"unauthorized"}}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"model":"test-model"}`)) + })) + defer server.Close() + + dir := t.TempDir() + store := aiconfig.NewStore(filepath.Join(dir, "aiconfigs.json")) + created, err := store.Create(aiconfig.AIConfig{ + Name: "Test Config", + Provider: aiconfig.ProviderCustom, + BaseURL: server.URL, + APIKey: storedKey, + Model: "test-model", + }) + if err != nil { + t.Fatalf("create config: %v", err) + } + + app := &App{aiConfigStore: store} + masked := maskAIKey(created).APIKey + + _, err = app.TestAIConfigPreview(created.ID, AIConfigPayload{ + APIKey: masked, + }) + if err != nil { + t.Fatalf("expected preview test to succeed, got error: %v (auth=%q)", err, gotAuth) + } + if gotAuth != expectedAuth { + t.Fatalf("expected auth %q, got %q", expectedAuth, gotAuth) + } +} diff --git a/app_auth.go b/app_auth.go new file mode 100644 index 0000000..13c3ccd --- /dev/null +++ b/app_auth.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "errors" + "net/url" + "strings" + + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/securefile" + + "github.com/wailsapp/wails/v2/pkg/options" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +func (a *App) CurrentAuth() (auth.State, error) { + if a.authService == nil { + return auth.State{}, nil + } + return a.authService.Current(context.Background()) +} + +func (a *App) EnsureAuthenticated() (auth.State, error) { + if a.authService == nil { + return auth.State{}, nil + } + return a.authService.EnsureAuthenticated(context.Background()) +} + +func (a *App) StartAuthLogin(input auth.StartLoginInput) (auth.LoginStart, error) { + if a.authService == nil { + return auth.LoginStart{}, errors.New("auth service is not configured") + } + return a.authService.StartLogin(context.Background(), input) +} + +func (a *App) PollAuthLogin() (auth.LoginPoll, error) { + if a.authService == nil { + return auth.LoginPoll{}, errors.New("auth service is not configured") + } + return a.authService.PollLogin(context.Background()) +} + +func (a *App) CompleteAuthLogin(code string) (auth.State, error) { + if a.authService == nil { + return auth.State{}, errors.New("auth service is not configured") + } + state, err := a.authService.CompleteAuthLogin(context.Background(), strings.TrimSpace(code)) + if err != nil { + return state, err + } + a.encryptExistingStores() + return state, nil +} + +func (a *App) LogoutAuth() (auth.State, error) { + if a.authService == nil { + return auth.State{}, nil + } + return a.authService.Logout(context.Background()) +} + +func (a *App) ListAuthDevices() (auth.DeviceList, error) { + if a.authService == nil { + return auth.DeviceList{}, errors.New("auth service is not configured") + } + return a.authService.ListDevices(context.Background()) +} + +func (a *App) RemoveAuthDevice(deviceID string) (auth.DeviceList, error) { + if a.authService == nil { + return auth.DeviceList{}, errors.New("auth service is not configured") + } + return a.authService.RemoveDevice(context.Background(), strings.TrimSpace(deviceID)) +} + +func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) { + a.handleLaunchArgs(data.Args) +} + +func (a *App) handleLaunchArgs(args []string) { + for _, arg := range args { + trimmed := strings.TrimSpace(arg) + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "futrix://") || strings.HasPrefix(lower, "futrixdata://") { + a.handleOpenURL(trimmed) + return + } + } +} + +func (a *App) handleOpenURL(rawURL string) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + a.emitAuthError(err) + return + } + if !strings.EqualFold(parsed.Scheme, "futrix") && !strings.EqualFold(parsed.Scheme, "futrixdata") { + return + } + callbackTarget := strings.Trim(strings.ToLower(parsed.Host+parsed.Path), "/") + if callbackTarget == "codex/connect" { + a.handleCodexConnectURL(parsed) + return + } + if callbackTarget == "auth/callback" { + callbackTarget = "callback" + } + if callbackTarget != "callback" { + return + } + if a.authService == nil { + return + } + code := strings.TrimSpace(parsed.Query().Get("code")) + if code == "" { + a.emitAuthError(errors.New("missing authorization code")) + return + } + go func() { + next, err := a.authService.CompleteAuthLogin(context.Background(), code) + if err != nil { + a.emitAuthError(err) + return + } + a.encryptExistingStores() + a.emitAuthState(next) + }() +} + +func (a *App) handleCodexConnectURL(parsed *url.URL) { + if strings.TrimSpace(a.cfg.DataPath) == "" { + return + } + if a.emitEvent != nil { + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + a.emitEvent(ctx, "codex:connect-request", map[string]any{ + "source": strings.TrimSpace(parsed.Query().Get("source")), + "authorizeUrl": parsed.String(), + }) + } +} + +func (a *App) encryptExistingStores() { + if securefile.Key() == nil { + return + } + if a.store != nil { + _ = a.store.Save() + } + if a.aiConfigStore != nil { + _ = a.aiConfigStore.Save() + } + if a.historyStore != nil { + _ = a.historyStore.Save() + } + if a.entityCache != nil { + _ = a.entityCache.Save() + } + if a.redisDocs != nil { + _ = a.redisDocs.Save() + } +} + +func (a *App) emitAuthState(state auth.State) { + if a.emitEvent != nil { + a.emitEvent(a.ctx, "auth:state", state) + return + } + if a.ctx != nil { + wailsruntime.EventsEmit(a.ctx, "auth:state", state) + } +} + +func (a *App) emitAuthError(err error) { + if err == nil { + return + } + message := err.Error() + if a.emitEvent != nil { + a.emitEvent(a.ctx, "auth:error", message) + return + } + if a.ctx != nil { + wailsruntime.EventsEmit(a.ctx, "auth:error", message) + } +} diff --git a/app_auth_test.go b/app_auth_test.go new file mode 100644 index 0000000..72fa450 --- /dev/null +++ b/app_auth_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "futrixdata/platform/internal/agentaudit" + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/securefile" +) + +func TestHandleOpenURLCompletesPendingLoginFromCallback(t *testing.T) { + var requestBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/client/exchange" { + w.WriteHeader(http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access_2", + "refresh_token": "refresh_2", + "expires_in": 900, + "user": map[string]any{ + "id": "user_123", + "email": "user@example.com", + "display_name": "Auth User", + }, + "license": map[string]any{ + "plan": "pro", + "status": "active", + }, + }) + })) + defer srv.Close() + + store := auth.NewStore(filepath.Join(t.TempDir(), "auth-session.json")) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + state := store.Current() + state.PendingLogin = &auth.PendingLogin{ + SessionID: "session_123", + CodeVerifier: "verifier_123", + LoginURL: srv.URL + "/app?session_id=session_123", + } + if err := store.Save(state); err != nil { + t.Fatalf("save state: %v", err) + } + + var emittedName string + var emittedState auth.State + app := &App{ + authStore: store, + authService: auth.NewService(auth.ServiceConfig{ + BaseURL: srv.URL, + Store: store, + HTTPClient: srv.Client(), + }), + emitEvent: func(ctx context.Context, eventName string, data ...interface{}) { + emittedName = eventName + if len(data) == 1 { + if next, ok := data[0].(auth.State); ok { + emittedState = next + } + } + }, + } + + app.handleOpenURL("futrix://callback?code=one-time-authorization-code") + + var current auth.State + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + current = store.Current() + if current.Session != nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if current.Session == nil { + t.Fatalf("expected callback to complete login") + } + if current.PendingLogin != nil { + t.Fatalf("expected pending login to be cleared") + } + if requestBody["code"] != "one-time-authorization-code" { + t.Fatalf("expected callback authorization code to be exchanged as-is, got %#v", requestBody["code"]) + } + if emittedName != "auth:state" { + t.Fatalf("expected auth:state event, got %q", emittedName) + } + if emittedState.Session == nil || emittedState.Session.User.Email != "user@example.com" { + t.Fatalf("expected emitted state to include session, got %#v", emittedState) + } +} + +func TestHandleOpenURLEmitsCodexConnectRequestBeforeAuthorization(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + key := bytes.Repeat([]byte{7}, 32) + securefile.SetKey(key) + t.Cleanup(securefile.ResetForTest) + + dataPath := filepath.Join(t.TempDir(), "datasources.json") + var emittedName string + var emittedPayload map[string]any + app := &App{ + cfg: Config{DataPath: dataPath}, + ctx: context.Background(), + emitEvent: func(ctx context.Context, eventName string, data ...interface{}) { + emittedName = eventName + if len(data) == 1 { + if payload, ok := data[0].(map[string]any); ok { + emittedPayload = payload + } + } + }, + } + + app.handleOpenURL("futrixdata://codex/connect?source=codex-plugin") + + bridgePath := filepath.Join(home, ".futrixdata", "codex-plugin.json") + if _, err := os.Stat(bridgePath); !os.IsNotExist(err) { + t.Fatalf("expected deep link to wait for user confirmation before writing bridge, stat err=%v", err) + } + if emittedName != "codex:connect-request" { + t.Fatalf("expected codex:connect-request event, got %q", emittedName) + } + if emittedPayload["source"] != "codex-plugin" { + t.Fatalf("expected source to round-trip, got %#v", emittedPayload["source"]) + } + + result := app.AuthorizeCodexPlugin() + if len(result.Installed) != 1 || !result.Installed[0].Success { + t.Fatalf("expected confirmed authorization to succeed, got %#v", result.Installed) + } + bridge, err := os.ReadFile(bridgePath) + if err != nil { + t.Fatalf("read codex plugin bridge: %v", err) + } + content := string(bridge) + for _, token := range []string{ + "futrixdata-cli", + "accessKey", + "agent_", + } { + if !strings.Contains(content, token) { + t.Fatalf("expected bridge to contain %q, got %s", token, content) + } + } + + identities, err := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)).ListAll() + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(identities) != 1 { + t.Fatalf("expected one codex identity, got %#v", identities) + } + if identities[0].AgentType != "codex" { + t.Fatalf("identity agent type = %q, want codex", identities[0].AgentType) + } +} + +func TestHandleLaunchArgsForwardsFutrixDataCodexDeepLink(t *testing.T) { + var emittedName string + var emittedPayload map[string]any + app := &App{ + cfg: Config{DataPath: filepath.Join(t.TempDir(), "datasources.json")}, + ctx: context.Background(), + emitEvent: func(ctx context.Context, eventName string, data ...interface{}) { + emittedName = eventName + if len(data) == 1 { + if payload, ok := data[0].(map[string]any); ok { + emittedPayload = payload + } + } + }, + } + + app.handleLaunchArgs([]string{ + "--already-running", + "futrixdata://codex/connect?source=codex-plugin", + }) + + if emittedName != "codex:connect-request" { + t.Fatalf("expected second-instance futrixdata deep link to emit codex:connect-request, got %q", emittedName) + } + if emittedPayload["source"] != "codex-plugin" { + t.Fatalf("expected source to round-trip, got %#v", emittedPayload["source"]) + } +} diff --git a/app_console.go b/app_console.go new file mode 100644 index 0000000..d70c18f --- /dev/null +++ b/app_console.go @@ -0,0 +1,987 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/riskengine" +) + +func (a *App) GetRedisCommandDocs(id string) (console.RedisCommandDocsEntry, error) { + ds, ok := a.store.Get(id) + if !ok { + return console.RedisCommandDocsEntry{}, errors.New("datasource not found") + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.get_redis_command_docs", ds, "", console.ExecuteOptions{}, false) + var err error + defer func() { finishTiming(err) }() + done := console.DatasourceTimingStage(ctx, "app.redis_docs.adapter_lookup") + adapter, err := a.manager.AdapterFor(ds.Type) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return console.RedisCommandDocsEntry{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + redisAdapter, ok := adapter.(*console.RedisAdapter) + if !ok { + err = errors.New("redis adapter not available") + return console.RedisCommandDocsEntry{}, err + } + done = console.DatasourceTimingStage(ctx, "redis.command_docs_store_get") + entry, err := a.redisDocs.Get(ctx, ds.ID, func(ctx context.Context) (map[string]any, error) { + // Resolve SecretRef-backed credentials; this path connects directly via the + // adapter and would otherwise use the empty password from the stored record. + resolved, err := a.manager.ResolveDatasource(ctx, ds) + if err != nil { + return nil, err + } + return console.FetchRedisCommandDocs(ctx, redisAdapter, resolved) + }) + status := "ok" + if err != nil { + status = "error" + } + done(console.DatasourceTimingKV("status", status), console.DatasourceTimingKV("commands", len(entry.Commands))) + return entry, err +} + +type RedisKeyPage struct { + Keys []string `json:"keys"` + Cursor string `json:"cursor"` + Done bool `json:"done"` +} + +const ( + elasticsearchIndicesMetaStatement = "GET /_cat/indices?format=json&h=index,health,store.size" + entitySchemaCacheRefreshInterval = 30 * time.Second +) + +func (a *App) ListEntities(id, pattern, database, executionMode string, forceRefresh bool) ([]string, error) { + ds, ok := a.store.Get(id) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.list_entities", ds, "", console.ExecuteOptions{}, false) + var err error + defer func() { finishTiming(err) }() + + cacheKey := entitySchemaCacheKey(ds) + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + if forceRefresh { + items, listErr := a.manager.ListEntities(ctx, ds, console.ListOptions{}) + err = listErr + if err == nil { + a.upsertEntityCacheEntities(ds, cacheKey, items, nil) + _ = a.entityCache.ClearDescribe(cacheKey) + _ = a.entityCache.MarkStale(cacheKey) + return filterCachedEntityNames(items, pattern), nil + } + if cached, _, ok := a.entityCache.GetEntities(cacheKey); ok { + err = nil + return filterCachedEntityNames(cached, pattern), nil + } + return nil, err + } + if cached, _, ok := a.entityCache.GetEntities(cacheKey); ok { + done := console.DatasourceTimingStage(ctx, "app.entity_cache") + done(console.DatasourceTimingKV("status", "hit"), console.DatasourceTimingKV("items", len(cached))) + a.refreshEntitySchemaCacheAsync(ds, cacheKey) + return filterCachedEntityNames(cached, pattern), nil + } + items, listErr := a.manager.ListEntities(ctx, ds, console.ListOptions{}) + err = listErr + if err != nil { + return nil, err + } + a.upsertEntityCacheEntities(ds, cacheKey, items, nil) + a.refreshEntitySchemaCacheAsync(ds, cacheKey) + return filterCachedEntityNames(items, pattern), nil + } + + result, listErr := a.manager.ListEntities(ctx, ds, console.ListOptions{Pattern: pattern}) + err = listErr + return result, err +} + +func (a *App) ListEntitiesPage(id, pattern, database, cursor string, limit int, executionMode string, forceRefresh bool) (console.EntityPage, error) { + ds, ok := a.store.Get(id) + if !ok { + return console.EntityPage{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.list_entities_page", ds, "", console.ExecuteOptions{PagingToken: cursor, PageSize: limit}, false) + var err error + defer func() { finishTiming(err) }() + + cacheKey := entitySchemaCacheKey(ds) + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + if forceRefresh { + items, kinds, listErr := a.listEntitiesWithKinds(ctx, ds) + err = listErr + if err == nil { + a.upsertEntityCacheEntitiesWithKinds(ds, cacheKey, items, kinds, nil) + _ = a.entityCache.ClearDescribe(cacheKey) + _ = a.entityCache.MarkStale(cacheKey) + return paginateCachedEntityNames(items, pattern, cursor, limit, nil, kinds), nil + } + if cached, _, ok := a.entityCache.GetEntities(cacheKey); ok { + err = nil + details := a.cachedDescribeDetailsForEntities(cacheKey, cached) + cachedKinds := a.entityCache.GetKinds(cacheKey) + return paginateCachedEntityNames(cached, pattern, cursor, limit, details, cachedKinds), nil + } + return console.EntityPage{}, err + } + if cached, _, ok := a.entityCache.GetEntities(cacheKey); ok { + done := console.DatasourceTimingStage(ctx, "app.entity_cache") + done(console.DatasourceTimingKV("status", "hit"), console.DatasourceTimingKV("items", len(cached))) + a.refreshEntitySchemaCacheAsync(ds, cacheKey) + details := a.cachedDescribeDetailsForEntities(cacheKey, cached) + kinds := a.entityCache.GetKinds(cacheKey) + if len(kinds) == 0 && datasourceSupportsKinds(ds.Type) { + kinds = a.collectEntityKinds(ctx, ds) + if len(kinds) > 0 { + a.upsertEntityCacheEntitiesWithKinds(ds, cacheKey, cached, kinds, nil) + } + } + return paginateCachedEntityNames(cached, pattern, cursor, limit, details, kinds), nil + } + items, kinds, listErr := a.listEntitiesWithKinds(ctx, ds) + err = listErr + if err != nil { + return console.EntityPage{}, err + } + a.upsertEntityCacheEntitiesWithKinds(ds, cacheKey, items, kinds, nil) + a.refreshEntitySchemaCacheAsync(ds, cacheKey) + details := a.cachedDescribeDetailsForEntities(cacheKey, items) + return paginateCachedEntityNames(items, pattern, cursor, limit, details, kinds), nil + } + + result, listErr := a.manager.ListEntitiesPage(ctx, ds, console.ListOptions{Limit: limit, Pattern: pattern}, cursor) + err = listErr + return result, err +} + +// datasourceSupportsKinds reports whether the datasource type emits entity kind +// metadata (table vs view). Only SQL-based adapters populate EntityPage.Kinds. +func datasourceSupportsKinds(dsType datasource.DataSourceType) bool { + switch dsType { + case datasource.TypeMySQL, datasource.TypePostgreSQL, datasource.TypeD1: + return true + } + return false +} + +// listEntitiesWithKinds fetches the full entity list together with kind metadata. +// For adapters that support kinds (MySQL, PostgreSQL, D1), it uses ListEntitiesPage +// which returns both items and kinds in a single call, avoiding a second round-trip. +func (a *App) listEntitiesWithKinds(ctx context.Context, ds datasource.DataSource) ([]string, map[string]string, error) { + if ctx == nil { + ctx = context.Background() + } + if !datasourceSupportsKinds(ds.Type) { + items, err := a.manager.ListEntities(ctx, ds, console.ListOptions{}) + return items, nil, err + } + // Use ListEntitiesPage to get items and kinds in one pass. + var allItems []string + allKinds := make(map[string]string) + cursor := "" + const pageLimit = 10000 + for { + page, err := a.manager.ListEntitiesPage(ctx, ds, console.ListOptions{Limit: pageLimit}, cursor) + if err != nil { + if len(allItems) > 0 { + break + } + // Fallback to ListEntities if ListEntitiesPage is not supported. + items, listErr := a.manager.ListEntities(ctx, ds, console.ListOptions{}) + if listErr != nil { + return nil, nil, listErr + } + return items, nil, nil + } + allItems = append(allItems, page.Items...) + for k, v := range page.Kinds { + allKinds[k] = v + } + if page.Done || page.Cursor == "" { + break + } + cursor = page.Cursor + } + return allItems, allKinds, nil +} + +// collectEntityKinds pages through ListEntitiesPage to accumulate kind metadata. +// Always returns a non-nil map so callers can distinguish "no views" from "not checked". +func (a *App) collectEntityKinds(ctx context.Context, ds datasource.DataSource) map[string]string { + const pageLimit = 10000 + allKinds := make(map[string]string) + cursor := "" + for { + page, err := a.manager.ListEntitiesPage(ctx, ds, console.ListOptions{Limit: pageLimit}, cursor) + if err != nil { + break + } + for k, v := range page.Kinds { + allKinds[k] = v + } + if page.Done || page.Cursor == "" { + break + } + cursor = page.Cursor + } + return allKinds +} + +func (a *App) ScanRedisKeys(id, pattern, cursor string) (RedisKeyPage, error) { + ds, ok := a.store.Get(id) + if !ok { + return RedisKeyPage{}, errors.New("datasource not found") + } + if ds.Type != datasource.TypeRedis { + return RedisKeyPage{}, errors.New("redis datasource required") + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.scan_redis_keys", ds, "", console.ExecuteOptions{PagingToken: cursor}, false) + var err error + defer func() { finishTiming(err) }() + done := console.DatasourceTimingStage(ctx, "app.redis_scan.adapter_lookup") + adapter, err := a.manager.AdapterFor(ds.Type) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return RedisKeyPage{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + scanner, ok := adapter.(console.KeyScanner) + if !ok { + err = console.ErrUnsupported + return RedisKeyPage{}, err + } + // Resolve SecretRef-backed credentials; this scan bypasses the manager dispatch + // path that normally resolves secrets, so an unresolved ds would authenticate + // with an empty password. + done = console.DatasourceTimingStage(ctx, "app.redis_scan.resolve_datasource") + ds, err = a.manager.ResolveDatasource(ctx, ds) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return RedisKeyPage{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + done = console.DatasourceTimingStage(ctx, "app.redis_scan.decode_cursor") + start, err := console.DecodeRedisCursor(cursor) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return RedisKeyPage{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + done = console.DatasourceTimingStage(ctx, "redis.scan_keys") + keys, next, scanDone, err := scanner.ScanKeys(ctx, ds, pattern, start) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return RedisKeyPage{}, err + } + done(console.DatasourceTimingKV("status", "ok"), console.DatasourceTimingKV("keys", len(keys)), console.DatasourceTimingKV("done", scanDone)) + done = console.DatasourceTimingStage(ctx, "app.redis_scan.encode_cursor") + encoded, err := console.EncodeRedisCursor(next) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return RedisKeyPage{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + return RedisKeyPage{Keys: keys, Cursor: encoded, Done: scanDone}, nil +} + +func (a *App) GetRedisKeyMeta(id string, keys []string) (map[string]console.RedisKeyMetaItem, error) { + if len(keys) == 0 { + return map[string]console.RedisKeyMetaItem{}, nil + } + ds, ok := a.store.Get(id) + if !ok { + return nil, errors.New("datasource not found") + } + if ds.Type != datasource.TypeRedis { + return nil, errors.New("redis datasource required") + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.get_redis_key_meta", ds, "", console.ExecuteOptions{}, false) + var err error + defer func() { finishTiming(err) }() + done := console.DatasourceTimingStage(ctx, "app.redis_key_meta.adapter_lookup") + adapter, err := a.manager.AdapterFor(ds.Type) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return nil, err + } + done(console.DatasourceTimingKV("status", "ok")) + provider, ok := adapter.(console.RedisKeyMetaProvider) + if !ok { + err = console.ErrUnsupported + return nil, err + } + // Resolve SecretRef-backed credentials; this direct adapter call bypasses the + // manager dispatch path that normally resolves secrets. + done = console.DatasourceTimingStage(ctx, "app.redis_key_meta.resolve_datasource") + ds, err = a.manager.ResolveDatasource(ctx, ds) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return nil, err + } + done(console.DatasourceTimingKV("status", "ok")) + done = console.DatasourceTimingStage(ctx, "redis.key_meta") + result, err := provider.GetKeyMeta(ctx, ds, keys) + status := "ok" + if err != nil { + status = "error" + } + done(console.DatasourceTimingKV("status", status), console.DatasourceTimingKV("keys", len(keys)), console.DatasourceTimingKV("items", len(result))) + return result, err +} + +func (a *App) DescribeEntity(id, name, database, executionMode string) (console.DescribeResult, error) { + ds, ok := a.store.Get(id) + if !ok { + return console.DescribeResult{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.describe_entity", ds, "", console.ExecuteOptions{}, false) + var err error + defer func() { finishTiming(err) }() + + entityName := strings.TrimSpace(name) + cacheKey := entitySchemaCacheKey(ds) + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + if cached, ok := a.entityCache.GetDescribe(cacheKey, entityName); ok { + if !a.entityCache.ShouldRefresh(cacheKey, entitySchemaCacheRefreshInterval) { + done := console.DatasourceTimingStage(ctx, "app.describe_cache") + done(console.DatasourceTimingKV("status", "hit")) + a.refreshEntityDescribeCacheAsync(ds, cacheKey, entityName) + return cached, nil + } + result, describeErr := a.manager.DescribeEntity(ctx, ds, entityName) + err = describeErr + if err == nil { + a.upsertEntityCacheDescribe(ds, cacheKey, entityName, result) + return result, nil + } + err = nil + return cached, nil + } + } + + result, describeErr := a.manager.DescribeEntity(ctx, ds, entityName) + err = describeErr + if err != nil { + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + if cached, ok := a.entityCache.GetDescribe(cacheKey, entityName); ok { + err = nil + return cached, nil + } + } + return console.DescribeResult{}, err + } + + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + a.upsertEntityCacheDescribe(ds, cacheKey, entityName, result) + } + return result, nil +} + +func (a *App) ExecuteStatement(id, statement, database, pagingToken string, pageSize int, executionMode string, approved bool, dynamoMaxReturnedRows, dynamoMaxPages, dynamoMaxEvaluatedItems int) (out console.QueryResult, err error) { + ds, ok := a.store.Get(id) + if !ok { + return console.QueryResult{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + + normalizedStatement := normalizeStatement(statement) + isElasticsearchIndicesMeta := ds.Type == datasource.TypeElasticsearch && isElasticsearchIndicesMetaRequest(normalizedStatement) + cacheKey := entitySchemaCacheKey(ds) + + if isElasticsearchIndicesMeta && a.entityCache != nil { + if entities, meta, ok := a.entityCache.GetEntities(cacheKey); ok && len(entities) > 0 { + a.refreshEntitySchemaCacheAsync(ds, cacheKey) + return buildCachedElasticsearchIndicesResult(entities, meta), nil + } + } + + execOpts := console.ExecuteOptions{ + PagingToken: pagingToken, + PageSize: pageSize, + Bounds: console.ExecuteBounds{ + MaxReturnedRows: dynamoMaxReturnedRows, + MaxPages: dynamoMaxPages, + MaxEvaluatedItems: dynamoMaxEvaluatedItems, + }, + } + ctx := context.Background() + finishTiming := func(error) {} + ctx, finishTiming = a.beginDatasourceTiming(ctx, "app.execute_statement", ds, statement, execOpts, approved) + defer func() { finishTiming(err) }() + done := console.DatasourceTimingStage(ctx, "app.apply_dynamodb_caps") + if err := a.applyDynamoDBRiskExecutionCaps(ds, statement, &execOpts); err != nil { + done(console.DatasourceTimingKV("status", "error")) + return console.QueryResult{}, err + } + done(console.DatasourceTimingKV("status", "ok")) + var result console.QueryResult + if approved { + result, err = a.manager.ExecuteWithInteractiveApproval(ctx, ds, statement, execOpts) + } else { + result, err = a.manager.Execute(ctx, ds, statement, execOpts) + } + if err != nil { + if riskInfo, ok := console.RiskInfoFromError(err); ok { + return console.QueryResult{RiskInfo: &riskInfo}, nil + } + if isElasticsearchIndicesMeta && a.entityCache != nil { + if entities, meta, ok := a.entityCache.GetEntities(cacheKey); ok && len(entities) > 0 { + return buildCachedElasticsearchIndicesResult(entities, meta), nil + } + } + return console.QueryResult{}, err + } + + if isElasticsearchIndicesMeta && a.entityCache != nil { + entities, meta := parseElasticsearchIndicesMetaRows(result.Rows) + if len(entities) > 0 { + a.upsertEntityCacheEntities(ds, cacheKey, entities, meta) + } + } + return result, nil +} + +func (a *App) applyDynamoDBRiskExecutionCaps(ds datasource.DataSource, statement string, opts *console.ExecuteOptions) error { + if a == nil || a.riskEngine == nil || opts == nil || ds.Type != datasource.TypeDynamoDB || !opts.Bounds.Enabled() { + return nil + } + policy := a.riskEngine.ProbePolicyForParsed(riskengine.ParseStatement(string(ds.Type), ds.ID, statement)) + return riskengine.ApplyDynamoDBExecutionPolicyCaps(ds, opts, policy) +} + +func (a *App) ExplainStatement(id, statement string, analyze bool, database, executionMode string) (console.ExplainResult, error) { + ds, ok := a.store.Get(id) + if !ok { + return console.ExplainResult{}, errors.New("datasource not found") + } + ds = datasourceWithDatabaseOverride(ds, database) + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + ctx := context.Background() + finishTiming := func(error) {} + ctx, finishTiming = a.beginDatasourceTiming(ctx, "app.explain_statement", ds, statement, console.ExecuteOptions{}, false) + statement = console.PrepareExplainStatement(statement, analyze, ds.Type) + result, err := a.manager.Explain(ctx, ds, statement) + finishTiming(err) + return result, err +} + +func (a *App) ListDatabases(id, pattern, executionMode string) ([]string, error) { + ds, ok := a.store.Get(id) + if !ok { + return nil, errors.New("datasource not found") + } + ds = datasourceWithD1ExecutionModeOverride(ds, executionMode) + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.list_databases", ds, "", console.ExecuteOptions{}, false) + result, err := a.manager.ListDatabases(ctx, ds, console.ListOptions{Pattern: pattern}) + finishTiming(err) + return result, err +} + +func (a *App) D1DeployMigrations(id string) (bool, error) { + ds, ok := a.store.Get(id) + if !ok { + return false, errors.New("datasource not found") + } + if ds.Type != datasource.TypeD1 { + return false, errors.New("d1 datasource required") + } + if !d1DatasourceSupportsDev(ds.Options) { + return false, errors.New("dev mode is not supported for this datasource") + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.d1_deploy_migrations", ds, "", console.ExecuteOptions{}, false) + var err error + defer func() { finishTiming(err) }() + done := console.DatasourceTimingStage(ctx, "app.d1_migrations.adapter_lookup") + adapter, err := a.manager.AdapterFor(ds.Type) + if err != nil { + done(console.DatasourceTimingKV("status", "error")) + return false, err + } + done(console.DatasourceTimingKV("status", "ok")) + d1Adapter, ok := adapter.(*console.D1Adapter) + if !ok { + err = errors.New("d1 adapter not available") + return false, err + } + done = console.DatasourceTimingStage(ctx, "d1.deploy_migrations") + if err = d1Adapter.DeployMigrations(ctx, ds); err != nil { + done(console.DatasourceTimingKV("status", "error")) + return false, err + } + done(console.DatasourceTimingKV("status", "ok")) + return true, nil +} + +func (a *App) ExportQueryResult(fileName, content string) (string, error) { + if strings.TrimSpace(content) == "" { + return "", errors.New("export content is empty") + } + + exportDir, err := resolveExportDirectory() + if err != nil { + return "", err + } + if err := os.MkdirAll(exportDir, 0o755); err != nil { + return "", err + } + + safeName := sanitizeExportFileName(fileName) + exportPath, err := nextExportFilePath(exportDir, safeName) + if err != nil { + return "", err + } + if err := os.WriteFile(exportPath, []byte(content), 0o644); err != nil { + return "", err + } + return exportPath, nil +} + +func resolveExportDirectory() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + downloads := filepath.Join(home, "Downloads") + info, err := os.Stat(downloads) + if err == nil && info.IsDir() { + return downloads, nil + } + return home, nil +} + +func sanitizeExportFileName(fileName string) string { + trimmed := strings.TrimSpace(fileName) + if trimmed == "" { + return fmt.Sprintf("query-result-%d.json", time.Now().Unix()) + } + base := filepath.Base(trimmed) + base = strings.TrimSpace(base) + if base == "" || base == "." || base == ".." { + return fmt.Sprintf("query-result-%d.json", time.Now().Unix()) + } + clean := strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '_' + default: + if r < 32 { + return -1 + } + return r + } + }, base) + clean = strings.TrimSpace(clean) + if clean == "" || clean == "." || clean == ".." { + return fmt.Sprintf("query-result-%d.json", time.Now().Unix()) + } + return clean +} + +func nextExportFilePath(dir, fileName string) (string, error) { + ext := filepath.Ext(fileName) + base := strings.TrimSuffix(fileName, ext) + candidate := filepath.Join(dir, fileName) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate, nil + } + for i := 1; i <= 9999; i++ { + next := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext)) + if _, err := os.Stat(next); errors.Is(err, os.ErrNotExist) { + return next, nil + } + } + return "", errors.New("unable to allocate export file path") +} + +func supportsEntitySchemaCache(ds datasource.DataSource) bool { + if ds.Type == datasource.TypeRedis { + return false + } + if ds.Type == datasource.TypeD1 && isD1LocalExecutionMode(ds) { + return false + } + return true +} + +func entitySchemaCacheKey(ds datasource.DataSource) string { + key := strings.TrimSpace(ds.ID) + if key == "" { + key = "unknown" + } + if ds.Type == datasource.TypeMongoDB { + db := strings.ToLower(strings.TrimSpace(ds.Database)) + if db != "" { + key = key + "::db::" + db + } + } + if ds.Type == datasource.TypeD1 { + key = key + "::mode::" + d1ExecutionModeForCache(ds) + } + return key +} + +func d1ExecutionModeForCache(ds datasource.DataSource) string { + mode := strings.ToLower(strings.TrimSpace(optionAnyString(ds.Options, "executionMode"))) + if mode == "dev" || mode == "remote" { + return mode + } + legacyMode := strings.ToLower(strings.TrimSpace(optionAnyString(ds.Options, "mode"))) + if legacyMode == "local" { + return "dev" + } + return "remote" +} + +func isD1LocalExecutionMode(ds datasource.DataSource) bool { + if ds.Type != datasource.TypeD1 { + return false + } + return d1ExecutionModeForCache(ds) == "dev" +} + +func filterCachedEntityNames(items []string, pattern string) []string { + normalized := normalizeCachedEntityNames(items) + trimmedPattern := strings.ToLower(strings.TrimSpace(pattern)) + if trimmedPattern == "" { + return normalized + } + out := make([]string, 0, len(normalized)) + for _, item := range normalized { + if strings.Contains(strings.ToLower(item), trimmedPattern) { + out = append(out, item) + } + } + return out +} + +func paginateCachedEntityNames( + items []string, + pattern, cursor string, + limit int, + details map[string]console.DescribeResult, + kinds map[string]string, +) console.EntityPage { + filtered := filterCachedEntityNames(items, pattern) + if limit <= 0 { + limit = 100 + } + start := 0 + trimmedCursor := strings.TrimSpace(cursor) + if trimmedCursor != "" { + idx := sort.SearchStrings(filtered, trimmedCursor) + if idx < len(filtered) && filtered[idx] == trimmedCursor { + start = idx + 1 + } else { + start = idx + } + } + if start >= len(filtered) { + return console.EntityPage{Items: []string{}, Cursor: "", Done: true} + } + end := start + limit + if end > len(filtered) { + end = len(filtered) + } + pageItems := append([]string(nil), filtered[start:end]...) + done := end >= len(filtered) + nextCursor := "" + if !done && len(pageItems) > 0 { + nextCursor = pageItems[len(pageItems)-1] + } + page := console.EntityPage{Items: pageItems, Cursor: nextCursor, Done: done} + if len(kinds) > 0 && len(pageItems) > 0 { + pageKinds := make(map[string]string) + for _, item := range pageItems { + if k, ok := kinds[item]; ok { + pageKinds[item] = k + } + } + if len(pageKinds) > 0 { + page.Kinds = pageKinds + } + } + if len(details) == 0 || len(pageItems) == 0 { + return page + } + page.Details = make(map[string]console.DescribeResult, len(pageItems)) + for _, item := range pageItems { + if detail, ok := details[item]; ok { + page.Details[item] = detail + } + } + if len(page.Details) == 0 { + page.Details = nil + } + return page +} + +func (a *App) cachedDescribeDetailsForEntities(cacheKey string, entities []string) map[string]console.DescribeResult { + if a.entityCache == nil || len(entities) == 0 { + return nil + } + entry, ok := a.entityCache.GetEntry(cacheKey) + if !ok || len(entry.Details) == 0 { + return nil + } + details := make(map[string]console.DescribeResult, len(entities)) + for _, name := range entities { + trimmedName := strings.TrimSpace(name) + if trimmedName == "" { + continue + } + detail, exists := entry.Details[trimmedName] + if !exists { + continue + } + details[trimmedName] = detail + } + if len(details) == 0 { + return nil + } + return details +} + +func normalizeCachedEntityNames(items []string) []string { + if len(items) == 0 { + return nil + } + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + sort.Strings(out) + return out +} + +func normalizeStatement(statement string) string { + fields := strings.Fields(strings.TrimSpace(statement)) + if len(fields) == 0 { + return "" + } + return strings.Join(fields, " ") +} + +func isElasticsearchIndicesMetaRequest(statement string) bool { + return strings.EqualFold(statement, elasticsearchIndicesMetaStatement) +} + +func parseElasticsearchIndicesMetaRows(rows []map[string]any) ([]string, map[string]console.ElasticsearchIndexMeta) { + if len(rows) == 0 { + return nil, nil + } + names := make([]string, 0, len(rows)) + meta := make(map[string]console.ElasticsearchIndexMeta, len(rows)) + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if row == nil { + continue + } + name := strings.TrimSpace(fmt.Sprint(row["index"])) + if name == "" { + continue + } + if _, ok := seen[name]; !ok { + names = append(names, name) + seen[name] = struct{}{} + } + meta[name] = console.ElasticsearchIndexMeta{ + Health: strings.TrimSpace(fmt.Sprint(row["health"])), + StoreSize: strings.TrimSpace(fmt.Sprint(row["store.size"])), + } + } + sort.Strings(names) + return names, meta +} + +func buildCachedElasticsearchIndicesResult(entities []string, meta map[string]console.ElasticsearchIndexMeta) console.QueryResult { + names := normalizeCachedEntityNames(entities) + rows := make([]map[string]any, 0, len(names)) + for _, name := range names { + m := meta[name] + row := map[string]any{"index": name} + if strings.TrimSpace(m.Health) != "" { + row["health"] = m.Health + } + if strings.TrimSpace(m.StoreSize) != "" { + row["store.size"] = m.StoreSize + } + rows = append(rows, row) + } + return console.QueryResult{ + Columns: []string{"index", "health", "store.size"}, + Rows: rows, + RowCount: int64(len(rows)), + HasMore: false, + NextToken: "", + PrevToken: "", + ElapsedMs: 0, + } +} + +func (a *App) refreshEntitySchemaCacheAsync(ds datasource.DataSource, cacheKey string) { + if a.entityCache == nil || !supportsEntitySchemaCache(ds) { + return + } + if !a.entityCache.ShouldRefresh(cacheKey, entitySchemaCacheRefreshInterval) { + return + } + if !a.entityCache.TryBeginRefresh(cacheKey) { + return + } + go func() { + defer a.entityCache.EndRefresh(cacheKey) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + if ds.Type == datasource.TypeElasticsearch { + result, err := a.manager.ExecuteInternal(ctx, ds, elasticsearchIndicesMetaStatement, console.ExecuteOptions{}) + if err == nil { + entities, meta := parseElasticsearchIndicesMetaRows(result.Rows) + if len(entities) > 0 { + a.upsertEntityCacheEntities(ds, cacheKey, entities, meta) + return + } + } + } + items, err := a.manager.ListEntities(ctx, ds, console.ListOptions{}) + if err == nil && len(items) > 0 { + var kinds map[string]string + if datasourceSupportsKinds(ds.Type) { + kinds = a.collectEntityKinds(ctx, ds) + } + a.upsertEntityCacheEntitiesWithKinds(ds, cacheKey, items, kinds, nil) + } + }() +} + +func (a *App) refreshEntityDescribeCacheAsync(ds datasource.DataSource, cacheKey, entityName string) { + if a.entityCache == nil || !supportsEntitySchemaCache(ds) { + return + } + if !a.entityCache.ShouldRefresh(cacheKey, entitySchemaCacheRefreshInterval) { + return + } + refreshKey := cacheKey + "::describe::" + entityName + if !a.entityCache.TryBeginRefresh(refreshKey) { + return + } + go func() { + defer a.entityCache.EndRefresh(refreshKey) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + result, err := a.manager.DescribeEntity(ctx, ds, entityName) + if err != nil { + return + } + a.upsertEntityCacheDescribe(ds, cacheKey, entityName, result) + }() +} + +func (a *App) upsertEntityCacheEntities(ds datasource.DataSource, cacheKey string, items []string, meta map[string]console.ElasticsearchIndexMeta) { + if a.entityCache == nil { + return + } + if err := a.entityCache.UpsertEntities(cacheKey, items, meta); err != nil { + return + } + a.syncSchemaKnowledgeAsync(ds, cacheKey) +} + +func (a *App) upsertEntityCacheEntitiesWithKinds(ds datasource.DataSource, cacheKey string, items []string, kinds map[string]string, meta map[string]console.ElasticsearchIndexMeta) { + if a.entityCache == nil { + return + } + if err := a.entityCache.UpsertEntitiesWithKinds(cacheKey, items, kinds, meta); err != nil { + return + } + a.syncSchemaKnowledgeAsync(ds, cacheKey) +} + +func (a *App) upsertEntityCacheDescribe(ds datasource.DataSource, cacheKey, entityName string, result console.DescribeResult) { + if a.entityCache == nil { + return + } + if err := a.entityCache.UpsertDescribe(cacheKey, entityName, result); err != nil { + return + } + a.syncSchemaKnowledgeAsync(ds, cacheKey) +} + +func (a *App) syncSchemaKnowledgeAsync(ds datasource.DataSource, cacheKey string) { + if a.entityCache == nil || a.schemaKB == nil { + return + } + if !a.schemaKB.TryBegin(cacheKey) { + return + } + go func() { + defer a.schemaKB.End(cacheKey) + entry, ok := a.entityCache.GetEntry(cacheKey) + if !ok || len(entry.Entities) == 0 { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + _ = a.schemaKB.SyncFromCache(ctx, ds, cacheKey, entry) + }() +} + +func datasourceWithDatabaseOverride(ds datasource.DataSource, database string) datasource.DataSource { + if ds.Type != datasource.TypeMongoDB { + return ds + } + trimmed := strings.TrimSpace(database) + if trimmed == "" { + return ds + } + ds.Database = trimmed + return ds +} + +func datasourceWithD1ExecutionModeOverride(ds datasource.DataSource, executionMode string) datasource.DataSource { + if ds.Type != datasource.TypeD1 { + return ds + } + mode := strings.ToLower(strings.TrimSpace(executionMode)) + if mode == "dev" && !d1DatasourceSupportsDev(ds.Options) { + mode = "remote" + } + if mode != "dev" && mode != "remote" { + return ds + } + next := ds + next.Options = copyDatasourceOptions(ds.Options) + next.Options["executionMode"] = mode + return next +} diff --git a/app_console_es_test.go b/app_console_es_test.go new file mode 100644 index 0000000..c1f45a5 --- /dev/null +++ b/app_console_es_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "path/filepath" + "testing" + + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/keyring" + "futrixdata/platform/internal/securefile" +) + +func TestNewApp_RegistersElasticsearchAdapter(t *testing.T) { + securefile.ResetForTest() + t.Cleanup(securefile.ResetForTest) + secrets := map[string]string{} + restore := keyring.UseBackendForTest( + func(service, account string) (string, error) { + value, ok := secrets[service+"/"+account] + if !ok { + return "", keyring.ErrNotFound + } + return value, nil + }, + func(service, account, secret string) error { + secrets[service+"/"+account] = secret + return nil + }, + ) + t.Cleanup(restore) + + tmp := t.TempDir() + cfg := Config{DataPath: filepath.Join(tmp, "datasources.json")} + app, err := NewApp(cfg) + if err != nil { + t.Fatalf("NewApp: %v", err) + } + if app.manager == nil { + t.Fatalf("expected console manager") + } + if _, err := app.manager.AdapterFor(datasource.TypeElasticsearch); err != nil { + t.Fatalf("expected elasticsearch adapter registered, got %v", err) + } +} diff --git a/app_console_test.go b/app_console_test.go new file mode 100644 index 0000000..230c3f3 --- /dev/null +++ b/app_console_test.go @@ -0,0 +1,945 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/riskengine" +) + +func TestDatasourceWithD1ExecutionModeOverride_RejectsDevWhenDatasourceDoesNotSupportDev(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1", + Type: datasource.TypeD1, + Options: map[string]any{ + "databaseId": "db_123", + "databaseName": "analytics", + }, + } + + next := datasourceWithD1ExecutionModeOverride(ds, "dev") + if got := optionAnyString(next.Options, "executionMode"); got != "remote" { + t.Fatalf("expected executionMode remote for non-dev datasource, got %q", got) + } +} + +func TestDatasourceWithD1ExecutionModeOverride_AllowsDevWhenDatasourceSupportsDev(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1", + Type: datasource.TypeD1, + Options: map[string]any{ + "databaseId": "db_123", + "databaseName": "analytics", + "supportDev": true, + "devProjectPath": "/tmp/project", + "wranglerConfigPath": "/tmp/project/wrangler.toml", + }, + } + + next := datasourceWithD1ExecutionModeOverride(ds, "dev") + if got := optionAnyString(next.Options, "executionMode"); got != "dev" { + t.Fatalf("expected executionMode dev, got %q", got) + } +} + +func TestDatasourceWithD1ExecutionModeOverride_AllowsDevForLegacyWranglerConfig(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1", + Type: datasource.TypeD1, + Options: map[string]any{ + "databaseId": "db_legacy", + "databaseName": "legacy_db", + "wranglerConfigPath": "/tmp/project/wrangler.toml", + }, + } + + next := datasourceWithD1ExecutionModeOverride(ds, "dev") + if got := optionAnyString(next.Options, "executionMode"); got != "dev" { + t.Fatalf("expected executionMode dev for legacy wrangler datasource, got %q", got) + } +} + +func TestExportQueryResult_WritesIntoDownloadsDirectory(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + app := &App{} + path, err := app.ExportQueryResult("mysql-result.csv", "id,name\n1,A\n") + if err != nil { + t.Fatalf("export query result: %v", err) + } + if filepath.Dir(path) != downloads { + t.Fatalf("expected export in downloads: got %q want dir %q", path, downloads) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read exported file: %v", err) + } + if string(data) != "id,name\n1,A\n" { + t.Fatalf("unexpected export content: %q", string(data)) + } +} + +func TestExportQueryResult_AppendsSuffixWhenFileExists(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + existing := filepath.Join(downloads, "mysql-result.csv") + if err := os.WriteFile(existing, []byte("old"), 0o644); err != nil { + t.Fatalf("write existing file: %v", err) + } + + app := &App{} + path, err := app.ExportQueryResult("mysql-result.csv", "new") + if err != nil { + t.Fatalf("export query result: %v", err) + } + if filepath.Base(path) != "mysql-result-1.csv" { + t.Fatalf("expected suffixed file name, got %q", filepath.Base(path)) + } +} + +func TestExportQueryResult_SanitizesTraversalName(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + app := &App{} + path, err := app.ExportQueryResult("../secret.csv", "x") + if err != nil { + t.Fatalf("export query result: %v", err) + } + if filepath.Dir(path) != downloads { + t.Fatalf("expected export under downloads, got %q", path) + } + if strings.Contains(path, "..") { + t.Fatalf("expected sanitized export path, got %q", path) + } + if filepath.Base(path) != "secret.csv" { + t.Fatalf("expected sanitized base file name secret.csv, got %q", filepath.Base(path)) + } +} + +func TestResolveExportDirectory_FallsBackToHomeWhenDownloadsMissing(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + path, err := resolveExportDirectory() + if err != nil { + t.Fatalf("resolve export directory: %v", err) + } + if path != tmpHome { + t.Fatalf("expected fallback to home directory, got %q want %q", path, tmpHome) + } +} + +type fakeConsoleAdapter struct { + listEntitiesFn func(ctx context.Context, ds datasource.DataSource, opts console.ListOptions) ([]string, error) + listEntitiesPageFn func(ctx context.Context, ds datasource.DataSource, opts console.ListOptions, cursor string) (console.EntityPage, error) + describeEntityFn func(ctx context.Context, ds datasource.DataSource, name string) (console.DescribeResult, error) + executeFn func(ctx context.Context, ds datasource.DataSource, statement string, opts console.ExecuteOptions) (console.QueryResult, error) +} + +type fakeRiskExecuteError struct { + message string + info console.ExecuteRiskInfo +} + +func (e fakeRiskExecuteError) Error() string { + return e.message +} + +func (e fakeRiskExecuteError) ExecuteRiskInfo() console.ExecuteRiskInfo { + return e.info +} + +type fakeConsoleInterceptor struct { + err error + lastOpts console.ExecuteOptions +} + +func (f *fakeConsoleInterceptor) BeforeExecute(_ context.Context, _ datasource.DataSource, _ string, opts console.ExecuteOptions) error { + f.lastOpts = opts + return f.err +} + +func (f *fakeConsoleAdapter) TestConnection(ctx context.Context, ds datasource.DataSource) error { + _ = ctx + _ = ds + return nil +} + +func (f *fakeConsoleAdapter) ListEntities(ctx context.Context, ds datasource.DataSource, opts console.ListOptions) ([]string, error) { + if f.listEntitiesFn == nil { + return nil, errors.New("list entities not configured") + } + return f.listEntitiesFn(ctx, ds, opts) +} + +func (f *fakeConsoleAdapter) ListEntitiesPage(ctx context.Context, ds datasource.DataSource, opts console.ListOptions, cursor string) (console.EntityPage, error) { + if f.listEntitiesPageFn == nil { + return console.EntityPage{}, errors.New("list entities page not configured") + } + return f.listEntitiesPageFn(ctx, ds, opts, cursor) +} + +func (f *fakeConsoleAdapter) DescribeEntity(ctx context.Context, ds datasource.DataSource, name string) (console.DescribeResult, error) { + if f.describeEntityFn == nil { + return console.DescribeResult{}, errors.New("describe entity not configured") + } + return f.describeEntityFn(ctx, ds, name) +} + +func (f *fakeConsoleAdapter) Execute(ctx context.Context, ds datasource.DataSource, statement string, opts console.ExecuteOptions) (console.QueryResult, error) { + if f.executeFn == nil { + return console.QueryResult{}, errors.New("execute not configured") + } + return f.executeFn(ctx, ds, statement, opts) +} + +func (f *fakeConsoleAdapter) Explain(ctx context.Context, ds datasource.DataSource, statement string) (console.ExplainResult, error) { + _ = ctx + _ = ds + _ = statement + return console.ExplainResult{}, nil +} + +func newConsoleEntityCacheTestApp(t *testing.T, ds datasource.DataSource, adapter *fakeConsoleAdapter) *App { + t.Helper() + + root := t.TempDir() + dataPath := filepath.Join(root, "datasources.json") + store := datasource.NewStore(dataPath) + if _, err := store.Create(ds); err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(ds.Type, adapter) + + entityCache := console.NewEntitySchemaCacheStore(filepath.Join(root, "entity-schema-cache.json")) + if err := entityCache.Load(); err != nil { + t.Fatalf("load entity cache: %v", err) + } + + return &App{ + store: store, + manager: manager, + entityCache: entityCache, + } +} + +func TestSupportsEntitySchemaCache_CoversAllExceptRedisAndD1Local(t *testing.T) { + if !supportsEntitySchemaCache(datasource.DataSource{Type: datasource.TypeMySQL}) { + t.Fatalf("expected mysql to support entity schema cache") + } + if !supportsEntitySchemaCache(datasource.DataSource{Type: datasource.TypePostgreSQL}) { + t.Fatalf("expected postgresql to support entity schema cache") + } + if !supportsEntitySchemaCache(datasource.DataSource{Type: datasource.TypeMongoDB}) { + t.Fatalf("expected mongodb to support entity schema cache") + } + if supportsEntitySchemaCache(datasource.DataSource{Type: datasource.TypeRedis}) { + t.Fatalf("expected redis to be excluded from entity schema cache") + } + if supportsEntitySchemaCache(datasource.DataSource{ + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "local", + }, + }) { + t.Fatalf("expected d1 local mode to be excluded from entity schema cache") + } + if !supportsEntitySchemaCache(datasource.DataSource{ + Type: datasource.TypeD1, + Options: map[string]any{ + "executionMode": "remote", + }, + }) { + t.Fatalf("expected d1 remote mode to support entity schema cache") + } +} + +func TestListEntitiesPage_MySQLFallsBackToEntityCacheWhenRemoteUnavailable(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "mysql", + Type: datasource.TypeMySQL, + } + + listEntitiesErr := error(nil) + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + if listEntitiesErr != nil { + return nil, listEntitiesErr + } + return []string{"users", "orders", "audit_logs"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, errors.New("remote paging unavailable") + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + firstPage, err := app.ListEntitiesPage(ds.ID, "", "", "", 2, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage first page: %v", err) + } + if !reflect.DeepEqual(firstPage.Items, []string{"audit_logs", "orders"}) { + t.Fatalf("expected first page items from cached snapshot, got %#v", firstPage.Items) + } + if firstPage.Done { + t.Fatalf("expected first page to have more results") + } + + listEntitiesErr = errors.New("remote list unavailable") + filteredPage, err := app.ListEntitiesPage(ds.ID, "ord", "", "", 10, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage cached filter fallback: %v", err) + } + if !reflect.DeepEqual(filteredPage.Items, []string{"orders"}) { + t.Fatalf("expected filtered cached items, got %#v", filteredPage.Items) + } +} + +func TestListEntitiesPage_MySQLIncludesCachedDescribeDetailsForCurrentPage(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "mysql", + Type: datasource.TypeMySQL, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{"users", "orders"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, errors.New("remote paging unavailable") + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, name string) (console.DescribeResult, error) { + switch name { + case "users": + return console.DescribeResult{ + Columns: []console.ColumnInfo{{Name: "id", DataType: "bigint", Nullable: "NO"}}, + Indexes: []console.IndexInfo{{Name: "PRIMARY", Column: "id", Unique: true}}, + }, nil + default: + return console.DescribeResult{}, nil + } + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + if _, err := app.DescribeEntity(ds.ID, "users", "", ""); err != nil { + t.Fatalf("DescribeEntity seed cache: %v", err) + } + + page, err := app.ListEntitiesPage(ds.ID, "users", "", "", 10, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage: %v", err) + } + detail, ok := page.Details["users"] + if !ok { + t.Fatalf("expected users detail in cached page payload") + } + if len(detail.Indexes) != 1 || detail.Indexes[0].Name != "PRIMARY" { + t.Fatalf("expected primary index detail, got %#v", detail.Indexes) + } +} + +func TestListEntitiesPage_D1LocalDoesNotExposeCachedDescribeDetails(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1", + Name: "d1-local", + Type: datasource.TypeD1, + Options: map[string]any{ + "supportDev": true, + "devProjectPath": "/tmp/project", + "wranglerConfigPath": "/tmp/project/wrangler.toml", + "executionMode": "dev", + }, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{"users"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{Items: []string{"users"}, Cursor: "", Done: true}, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{ + Columns: []console.ColumnInfo{{Name: "id", DataType: "integer", Nullable: "NO"}}, + Indexes: []console.IndexInfo{{Name: "PRIMARY", Column: "id", Unique: true}}, + }, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + if _, err := app.DescribeEntity(ds.ID, "users", "", "dev"); err != nil { + t.Fatalf("DescribeEntity local d1: %v", err) + } + + page, err := app.ListEntitiesPage(ds.ID, "", "", "", 10, "dev", false) + if err != nil { + t.Fatalf("ListEntitiesPage local d1: %v", err) + } + if len(page.Details) != 0 { + t.Fatalf("expected no cached describe details for d1 local, got %#v", page.Details) + } +} + +func TestListEntitiesPage_ForceRefreshBypassesCacheSnapshot(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "mysql", + Type: datasource.TypeMySQL, + } + + entityRows := []string{"users_old", "orders_old"} + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return append([]string(nil), entityRows...), nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, errors.New("remote paging unavailable") + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + oldPage, err := app.ListEntitiesPage(ds.ID, "", "", "", 10, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage first load: %v", err) + } + if !reflect.DeepEqual(oldPage.Items, []string{"orders_old", "users_old"}) { + t.Fatalf("expected initial cached entities, got %#v", oldPage.Items) + } + + entityRows = []string{"users_new", "payments_new"} + forcedPage, err := app.ListEntitiesPage(ds.ID, "", "", "", 10, "", true) + if err != nil { + t.Fatalf("ListEntitiesPage forced load: %v", err) + } + if !reflect.DeepEqual(forcedPage.Items, []string{"payments_new", "users_new"}) { + t.Fatalf("expected forced refresh entities, got %#v", forcedPage.Items) + } +} + +func TestListEntitiesPage_D1ReturnsViewKinds(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1_cloud", + Name: "d1-eps", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + }, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{"conversion_stats", "conversions", "rate_limits"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{ + Items: []string{"conversion_stats", "conversions", "rate_limits"}, + Kinds: map[string]string{"conversion_stats": "view"}, + Done: true, + }, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + // Cold load — no cache yet + page, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage cold: %v", err) + } + if page.Kinds == nil || page.Kinds["conversion_stats"] != "view" { + t.Fatalf("cold load: expected conversion_stats=view in kinds, got %v", page.Kinds) + } + + // Second call — should hit cache and still return kinds + page2, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage cached: %v", err) + } + if page2.Kinds == nil || page2.Kinds["conversion_stats"] != "view" { + t.Fatalf("cached load: expected conversion_stats=view in kinds, got %v", page2.Kinds) + } + + // Force refresh — should also return kinds + page3, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", true) + if err != nil { + t.Fatalf("ListEntitiesPage forced: %v", err) + } + if page3.Kinds == nil || page3.Kinds["conversion_stats"] != "view" { + t.Fatalf("forced refresh: expected conversion_stats=view in kinds, got %v", page3.Kinds) + } +} + +func TestListEntitiesPage_D1LegacyCacheGetsKindsBackfilled(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1_legacy", + Name: "d1-eps", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + }, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{"conversion_stats", "conversions", "rate_limits"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{ + Items: []string{"conversion_stats", "conversions", "rate_limits"}, + Kinds: map[string]string{"conversion_stats": "view"}, + Done: true, + }, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + // Simulate legacy cache: upsert entities WITHOUT kinds + cacheKey := entitySchemaCacheKey(ds) + _ = app.entityCache.UpsertEntitiesWithKinds(cacheKey, []string{"conversion_stats", "conversions", "rate_limits"}, nil, nil) + + // Now call ListEntitiesPage — cache hit path, but no kinds in cache + page, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage with legacy cache: %v", err) + } + t.Logf("Legacy cache result: items=%v kinds=%v", page.Items, page.Kinds) + if page.Kinds == nil || page.Kinds["conversion_stats"] != "view" { + t.Fatalf("legacy cache: expected conversion_stats=view in kinds, got %v", page.Kinds) + } +} + +func TestListEntitiesPage_D1LegacyCacheWithFailingAPI(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1_failing", + Name: "d1-eps", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + }, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return nil, errors.New("api error") + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, errors.New("api error") + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + // Simulate legacy cache: entities without kinds + cacheKey := entitySchemaCacheKey(ds) + _ = app.entityCache.UpsertEntitiesWithKinds(cacheKey, []string{"conversion_stats", "conversions", "rate_limits"}, nil, nil) + + // ListEntitiesPage — cache hit path, but API fails so collectEntityKinds returns empty + page, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage with legacy cache and failing API: %v", err) + } + t.Logf("Legacy cache + failing API: items=%v kinds=%v", page.Items, page.Kinds) + // This will likely FAIL — no kinds returned because collectEntityKinds fails silently + if page.Kinds == nil || page.Kinds["conversion_stats"] != "view" { + t.Logf("BUG CONFIRMED: legacy cache + failing API = no kinds. kinds=%v", page.Kinds) + } +} + +func TestListEntitiesPage_D1LegacyCacheWithWorkingAPI(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_d1_working", + Name: "d1-eps", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + }, + } + + pageCallCount := 0 + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{"conversion_stats", "conversions", "rate_limits"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + pageCallCount++ + return console.EntityPage{ + Items: []string{"conversion_stats", "conversions", "rate_limits"}, + Kinds: map[string]string{"conversion_stats": "view"}, + Done: true, + }, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + // Simulate legacy cache: entities without kinds (like user's existing cache) + cacheKey := entitySchemaCacheKey(ds) + _ = app.entityCache.UpsertEntitiesWithKinds(cacheKey, []string{"conversion_stats", "conversions", "rate_limits"}, nil, nil) + + // ListEntitiesPage with working API — should backfill kinds synchronously + page, err := app.ListEntitiesPage(ds.ID, "", "", "", 200, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage: %v", err) + } + t.Logf("Result: items=%v kinds=%v pageCallCount=%d", page.Items, page.Kinds, pageCallCount) + if page.Kinds == nil || page.Kinds["conversion_stats"] != "view" { + t.Fatalf("expected conversion_stats=view in kinds, got %v (pageCallCount=%d)", page.Kinds, pageCallCount) + } +} + +func TestListEntitiesPage_DynamoDBFallsBackToEntityCacheWhenRemoteUnavailable(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_dynamodb", + Name: "ddb", + Type: datasource.TypeDynamoDB, + } + + listEntitiesErr := error(nil) + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + if listEntitiesErr != nil { + return nil, listEntitiesErr + } + return []string{"users", "orders", "audit_logs"}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, errors.New("remote paging unavailable") + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + firstPage, err := app.ListEntitiesPage(ds.ID, "", "", "", 2, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage first page: %v", err) + } + if !reflect.DeepEqual(firstPage.Items, []string{"audit_logs", "orders"}) { + t.Fatalf("expected first page items from cached snapshot, got %#v", firstPage.Items) + } + if firstPage.Done { + t.Fatalf("expected first page to have more results") + } + + listEntitiesErr = errors.New("remote list unavailable") + filteredPage, err := app.ListEntitiesPage(ds.ID, "ord", "", "", 10, "", false) + if err != nil { + t.Fatalf("ListEntitiesPage cached filter fallback: %v", err) + } + if !reflect.DeepEqual(filteredPage.Items, []string{"orders"}) { + t.Fatalf("expected filtered cached items, got %#v", filteredPage.Items) + } + if !filteredPage.Done { + t.Fatalf("expected filtered page to be done") + } +} + +func TestDescribeEntity_DynamoDBFallsBackToEntityCacheWhenRemoteUnavailable(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_dynamodb", + Name: "ddb", + Type: datasource.TypeDynamoDB, + } + + describeErr := error(nil) + expected := console.DescribeResult{ + Columns: []console.ColumnInfo{{Name: "id", DataType: "S"}}, + Indexes: []console.IndexInfo{{Name: "PRIMARY", Column: "id", Unique: true}}, + Details: []console.DetailItem{{Label: "Table", Value: "orders"}}, + Dialect: "partiql", + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + if describeErr != nil { + return console.DescribeResult{}, describeErr + } + return expected, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + initial, err := app.DescribeEntity(ds.ID, "orders", "", "") + if err != nil { + t.Fatalf("DescribeEntity initial: %v", err) + } + if !reflect.DeepEqual(initial, expected) { + t.Fatalf("expected initial describe result, got %#v", initial) + } + + describeErr = errors.New("describe unavailable") + cached, err := app.DescribeEntity(ds.ID, "orders", "", "") + if err != nil { + t.Fatalf("DescribeEntity cached fallback: %v", err) + } + if !reflect.DeepEqual(cached, expected) { + t.Fatalf("expected cached describe result, got %#v", cached) + } +} + +func TestAppApplyDynamoDBRiskExecutionCaps(t *testing.T) { + maxPages := 2 + maxEvaluatedItems := 120 + engine := riskengine.NewEngine() + engine.LoadUserRules([]riskengine.Rule{ + { + ID: "tight-dynamodb-budget", + Scope: riskengine.RuleScope{DsTypes: []string{"dynamodb"}}, + Enabled: true, + Priority: 200, + Action: riskengine.ActionAllow, + When: riskengine.RuleCondition{Command: []string{"select"}}, + Thresholds: riskengine.RuleThresholds{ + MaxDynamoDBPages: &maxPages, + MaxDynamoDBEvaluatedItems: &maxEvaluatedItems, + }, + }, + }) + app := &App{riskEngine: engine} + opts := console.ExecuteOptions{ + Bounds: console.ExecuteBounds{ + MaxReturnedRows: 100, + MaxPages: 20, + MaxEvaluatedItems: 5000, + }, + } + + if err := app.applyDynamoDBRiskExecutionCaps( + datasource.DataSource{ID: "ds_dynamodb", Type: datasource.TypeDynamoDB}, + `SELECT * FROM "orders"`, + &opts, + ); err != nil { + t.Fatalf("applyDynamoDBRiskExecutionCaps: %v", err) + } + + if opts.Bounds.MaxPages != maxPages { + t.Fatalf("MaxPages = %d, want %d", opts.Bounds.MaxPages, maxPages) + } + if opts.Bounds.MaxEvaluatedItems != maxEvaluatedItems { + t.Fatalf("MaxEvaluatedItems = %d, want %d", opts.Bounds.MaxEvaluatedItems, maxEvaluatedItems) + } + if opts.Bounds.MaxReturnedRows != 100 { + t.Fatalf("MaxReturnedRows = %d, want unchanged 100", opts.Bounds.MaxReturnedRows) + } +} + +func TestExecuteStatement_WailsBindingUsesFixedArguments(t *testing.T) { + methodType := reflect.TypeOf((&App{}).ExecuteStatement) + if methodType.IsVariadic() { + t.Fatal("ExecuteStatement must not be variadic; Wails runtime rejects extra positional arguments") + } + if got, want := methodType.NumIn(), 10; got != want { + t.Fatalf("ExecuteStatement argument count = %d, want %d", got, want) + } +} + +func TestExecuteStatement_ElasticsearchCatIndicesFallsBackToEntityCache(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_es", + Name: "es", + Type: datasource.TypeElasticsearch, + } + + execErr := error(nil) + expected := console.QueryResult{ + Rows: []map[string]any{ + {"index": "futrixdata-demo-1", "health": "green", "store.size": "12mb"}, + {"index": "futrixdata-demo-2", "health": "yellow", "store.size": "48mb"}, + }, + RowCount: 2, + } + + adapter := &fakeConsoleAdapter{ + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return []string{}, nil + }, + listEntitiesPageFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions, _ string) (console.EntityPage, error) { + return console.EntityPage{}, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + if execErr != nil { + return console.QueryResult{}, execErr + } + return expected, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + + statement := "GET /_cat/indices?format=json&h=index,health,store.size" + initial, err := app.ExecuteStatement(ds.ID, statement, "", "", 10000, "", false, 0, 0, 0) + if err != nil { + t.Fatalf("ExecuteStatement initial: %v", err) + } + if !reflect.DeepEqual(initial.Rows, expected.Rows) { + t.Fatalf("expected initial cat indices rows, got %#v", initial.Rows) + } + + execErr = errors.New("cat indices unavailable") + cached, err := app.ExecuteStatement(ds.ID, statement, "", "", 10000, "", false, 0, 0, 0) + if err != nil { + t.Fatalf("ExecuteStatement cached fallback: %v", err) + } + if !reflect.DeepEqual(cached.Rows, expected.Rows) { + t.Fatalf("expected cached cat indices rows, got %#v", cached.Rows) + } +} + +func TestExecuteStatement_PreservesStructuredRiskError(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "mysql", + Type: datasource.TypeMySQL, + } + adapter := &fakeConsoleAdapter{ + executeFn: func(_ context.Context, _ datasource.DataSource, _ string, _ console.ExecuteOptions) (console.QueryResult, error) { + t.Fatal("adapter Execute should not be called when interceptor blocks") + return console.QueryResult{}, nil + }, + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return nil, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + app.manager.SetInterceptor(&fakeConsoleInterceptor{ + err: fakeRiskExecuteError{ + message: "statement stopped for review: DELETE", + info: console.ExecuteRiskInfo{ + Action: "warn", + Level: "medium", + Reasons: []string{"DELETE"}, + RuleID: "sql-warn-delete", + TargetEntity: "users", + }, + }, + }) + + result, err := app.ExecuteStatement(ds.ID, "DELETE FROM users WHERE id = 1", "", "", 100, "", false, 0, 0, 0) + if err != nil { + t.Fatalf("expected no error (risk info in result), got: %v", err) + } + if result.RiskInfo == nil { + t.Fatal("expected RiskInfo in result") + } + if result.RiskInfo.Action != "warn" { + t.Fatalf("action = %q, want warn", result.RiskInfo.Action) + } + if result.RiskInfo.TargetEntity != "users" { + t.Fatalf("targetEntity = %q, want users", result.RiskInfo.TargetEntity) + } +} + +func TestExecuteStatement_ApprovedConsoleRunUsesInteractiveApprovalPath(t *testing.T) { + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "mysql", + Type: datasource.TypeMySQL, + } + adapter := &fakeConsoleAdapter{ + executeFn: func(_ context.Context, _ datasource.DataSource, statement string, _ console.ExecuteOptions) (console.QueryResult, error) { + if statement != "DELETE FROM users" { + t.Fatalf("unexpected statement: %s", statement) + } + return console.QueryResult{Rows: []map[string]any{{"ok": true}}}, nil + }, + listEntitiesFn: func(_ context.Context, _ datasource.DataSource, _ console.ListOptions) ([]string, error) { + return nil, nil + }, + describeEntityFn: func(_ context.Context, _ datasource.DataSource, _ string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil + }, + } + app := newConsoleEntityCacheTestApp(t, ds, adapter) + app.manager.SetInterceptor(riskengine.NewGuard(riskengine.NewEngine())) + + if _, err := app.ExecuteStatement(ds.ID, "DELETE FROM users", "", "", 100, "", true, 0, 0, 0); err != nil { + t.Fatalf("execute statement: %v", err) + } +} diff --git a/app_datasource.go b/app_datasource.go new file mode 100644 index 0000000..2a9e5d7 --- /dev/null +++ b/app_datasource.go @@ -0,0 +1,2809 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "reflect" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/pkg/browser" + + "futrixdata/platform/internal/commandutil" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/secrets" +) + +type DataSourcePayload struct { + Name string `json:"name"` + Type datasource.DataSourceType `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + Database string `json:"database"` + AuthSource string `json:"authSource"` + Options map[string]any `json:"options"` + SecretRefs map[string]secrets.SecretRef `json:"secretRefs,omitempty"` +} + +type D1OAuthSession struct { + Accounts []D1OAuthAccount `json:"accounts"` + AccountID string `json:"accountId"` + Token string `json:"token"` +} + +type D1OAuthAccount struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type D1CloudDatabase struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type DynamoDBSSOProfile struct { + Name string `json:"name"` + Region string `json:"region"` + SSORegion string `json:"ssoRegion"` + StartURL string `json:"startUrl"` + AccountID string `json:"accountId"` + RoleName string `json:"roleName"` +} + +type DynamoDBSSOLoginResult struct { + AccessToken string `json:"accessToken"` + ExpiresAt string `json:"expiresAt"` +} + +type DynamoDBSSOAccount struct { + AccountID string `json:"accountId"` + AccountName string `json:"accountName"` + EmailAddress string `json:"emailAddress"` +} + +type DynamoDBSSORole struct { + RoleName string `json:"roleName"` + AccountID string `json:"accountId"` +} + +type DynamoDBSSORoleCredentials struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` +} + +type DynamoDBSSOOAuthResult struct { + Profile string `json:"profile"` + Region string `json:"region"` + AccountID string `json:"accountId"` + RoleName string `json:"roleName"` + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` +} + +type dynamoDBSSOClient interface { + ListAccounts(ctx context.Context, params dynamoDBSSOListAccountsInput) (dynamoDBSSOListAccountsOutput, error) + ListAccountRoles(ctx context.Context, params dynamoDBSSOListAccountRolesInput) (dynamoDBSSOListAccountRolesOutput, error) + GetRoleCredentials(ctx context.Context, params dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) +} + +type dynamoDBSSOOIDCClient interface { + RegisterClient(ctx context.Context, params dynamoDBSSOOIDCRegisterClientInput) (dynamoDBSSOOIDCRegisterClientOutput, error) + StartDeviceAuthorization(ctx context.Context, params dynamoDBSSOOIDCStartDeviceAuthorizationInput) (dynamoDBSSOOIDCStartDeviceAuthorizationOutput, error) + CreateToken(ctx context.Context, params dynamoDBSSOOIDCCreateTokenInput) (dynamoDBSSOOIDCCreateTokenOutput, error) +} + +type dynamoDBSSOListAccountsInput struct { + AccessToken string + NextToken string + MaxResults int32 +} + +type dynamoDBSSOListAccountsOutput struct { + AccountList []dynamoDBSSOAccountInfo + NextToken string +} + +type dynamoDBSSOAccountInfo struct { + AccountID string + AccountName string + EmailAddress string +} + +type dynamoDBSSOListAccountRolesInput struct { + AccountID string + AccessToken string + NextToken string + MaxResults int32 +} + +type dynamoDBSSOListAccountRolesOutput struct { + RoleList []dynamoDBSSORoleInfo + NextToken string +} + +type dynamoDBSSORoleInfo struct { + RoleName string + AccountID string +} + +type dynamoDBSSOGetRoleCredentialsInput struct { + AccountID string + RoleName string + AccessToken string +} + +type dynamoDBSSOGetRoleCredentialsOutput struct { + RoleCredentials *dynamoDBSSORoleCredentialsOutput +} + +type dynamoDBSSORoleCredentialsOutput struct { + AccessKeyID string + SecretAccessKey string + SessionToken string + Expiration int64 +} + +type dynamoDBSSOOIDCRegisterClientInput struct { + ClientName string + ClientType string + GrantTypes []string + Scopes []string +} + +type dynamoDBSSOOIDCRegisterClientOutput struct { + ClientID string + ClientSecret string +} + +type dynamoDBSSOOIDCStartDeviceAuthorizationInput struct { + ClientID string + ClientSecret string + StartURL string +} + +type dynamoDBSSOOIDCStartDeviceAuthorizationOutput struct { + DeviceCode string + VerificationURI string + VerificationURIComplete string + UserCode string + ExpiresIn int32 + Interval int32 +} + +type dynamoDBSSOOIDCCreateTokenInput struct { + ClientID string + ClientSecret string + GrantType string + DeviceCode string +} + +type dynamoDBSSOOIDCCreateTokenOutput struct { + AccessToken string + ExpiresIn int32 +} + +type dynamoDBSSOHTTPClient struct { + baseURL string + httpClient *http.Client + initErr error +} + +type dynamoDBSSOOIDCHTTPClient struct { + baseURL string + httpClient *http.Client + initErr error +} + +type dynamoDBSSOAPIError struct { + Service string + Code string + Message string + StatusCode int +} + +func (e *dynamoDBSSOAPIError) Error() string { + if e == nil { + return "" + } + code := strings.TrimSpace(e.Code) + msg := strings.TrimSpace(e.Message) + switch { + case code == "" && msg == "": + return fmt.Sprintf("%s request failed", strings.TrimSpace(e.Service)) + case code == "": + return msg + case msg == "": + return code + default: + return code + ": " + msg + } +} + +func (e *dynamoDBSSOAPIError) hasCode(code string) bool { + if e == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(e.Code), strings.TrimSpace(code)) +} + +var resolveDynamoDBSSOPortalEndpoint = func(region string) (string, error) { + return awsRegionalEndpoint("portal.sso", region) +} + +var resolveDynamoDBSSOOIDCEndpoint = func(region string) (string, error) { + return awsRegionalEndpoint("oidc", region) +} + +var newDynamoDBSSOClient = func(region string, httpClient *http.Client) dynamoDBSSOClient { + endpoint, err := resolveDynamoDBSSOPortalEndpoint(region) + return &dynamoDBSSOHTTPClient{ + baseURL: endpoint, + httpClient: httpClient, + initErr: err, + } +} + +var newDynamoDBSSOOIDCClient = func(region string, httpClient *http.Client) dynamoDBSSOOIDCClient { + endpoint, err := resolveDynamoDBSSOOIDCEndpoint(region) + return &dynamoDBSSOOIDCHTTPClient{ + baseURL: endpoint, + httpClient: httpClient, + initErr: err, + } +} + +var openDynamoDBSSOVerificationURL = func(rawURL string) error { + return browser.OpenURL(rawURL) +} + +var waitDynamoDBSSOPollInterval = func(ctx context.Context, wait time.Duration) error { + if wait <= 0 { + return nil + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func awsRegionalEndpoint(servicePrefix, region string) (string, error) { + region = strings.TrimSpace(region) + if region == "" { + return "", errors.New("region is required") + } + suffix := "amazonaws.com" + switch { + case strings.HasPrefix(region, "cn-"): + suffix = "amazonaws.com.cn" + case strings.HasPrefix(region, "us-iso-"): + suffix = "c2s.ic.gov" + case strings.HasPrefix(region, "us-isob-"): + suffix = "sc2s.sgov.gov" + } + return fmt.Sprintf("https://%s.%s.%s", servicePrefix, region, suffix), nil +} + +func resolvedDynamoDBSSOHTTPClient(client *http.Client) *http.Client { + if client != nil { + return client + } + return &http.Client{Timeout: 20 * time.Second} +} + +func sanitizeDynamoDBSSOErrorCode(raw string) string { + code := strings.TrimSpace(raw) + if code == "" { + return "" + } + if idx := strings.Index(code, ":"); idx >= 0 { + code = code[:idx] + } + if idx := strings.LastIndex(code, "#"); idx >= 0 && idx < len(code)-1 { + code = code[idx+1:] + } + return strings.TrimSpace(code) +} + +func parseDynamoDBSSOAPIError(service string, statusCode int, header http.Header, body []byte) *dynamoDBSSOAPIError { + code := sanitizeDynamoDBSSOErrorCode(header.Get("X-Amzn-Errortype")) + msg := "" + if len(body) > 0 { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err == nil { + for _, key := range []string{"message", "Message", "error_description"} { + if raw, ok := payload[key]; ok && raw != nil { + msg = strings.TrimSpace(fmt.Sprint(raw)) + if msg != "" { + break + } + } + } + if code == "" { + for _, key := range []string{"code", "Code", "__type", "error"} { + if raw, ok := payload[key]; ok && raw != nil { + code = sanitizeDynamoDBSSOErrorCode(fmt.Sprint(raw)) + if code != "" { + break + } + } + } + } + } + } + if code == "" { + code = fmt.Sprintf("HTTP%d", statusCode) + } + return &dynamoDBSSOAPIError{ + Service: strings.TrimSpace(service), + Code: code, + Message: strings.TrimSpace(msg), + StatusCode: statusCode, + } +} + +func dynamoDBSSOAPIRequest( + ctx context.Context, + httpClient *http.Client, + method string, + rawURL string, + headers map[string]string, + body any, + out any, + service string, +) error { + if ctx == nil { + ctx = context.Background() + } + var bodyReader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for key, value := range headers { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + req.Header.Set(key, trimmed) + } + } + resp, err := resolvedDynamoDBSSOHTTPClient(httpClient).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + rawResp, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return parseDynamoDBSSOAPIError(service, resp.StatusCode, resp.Header, rawResp) + } + if out == nil || len(rawResp) == 0 { + return nil + } + if err := json.Unmarshal(rawResp, out); err != nil { + return fmt.Errorf("decode %s response: %w", strings.TrimSpace(service), err) + } + return nil +} + +func (c *dynamoDBSSOHTTPClient) validate() error { + if c == nil { + return errors.New("aws sso client is not initialized") + } + if c.initErr != nil { + return c.initErr + } + if strings.TrimSpace(c.baseURL) == "" { + return errors.New("aws sso endpoint is not configured") + } + return nil +} + +func (c *dynamoDBSSOOIDCHTTPClient) validate() error { + if c == nil { + return errors.New("aws sso oidc client is not initialized") + } + if c.initErr != nil { + return c.initErr + } + if strings.TrimSpace(c.baseURL) == "" { + return errors.New("aws sso oidc endpoint is not configured") + } + return nil +} + +func (c *dynamoDBSSOHTTPClient) ListAccounts(ctx context.Context, params dynamoDBSSOListAccountsInput) (dynamoDBSSOListAccountsOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOListAccountsOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/assignment/accounts" + query := url.Values{} + if params.MaxResults > 0 { + query.Set("max_result", strconv.FormatInt(int64(params.MaxResults), 10)) + } + if token := strings.TrimSpace(params.NextToken); token != "" { + query.Set("next_token", token) + } + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + var resp struct { + AccountList []struct { + AccountID string `json:"accountId"` + AccountName string `json:"accountName"` + EmailAddress string `json:"emailAddress"` + } `json:"accountList"` + NextToken string `json:"nextToken"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodGet, + endpoint, + map[string]string{"X-Amz-Sso_bearer_token": strings.TrimSpace(params.AccessToken)}, + nil, + &resp, + "aws sso list-accounts", + ); err != nil { + return dynamoDBSSOListAccountsOutput{}, err + } + out := dynamoDBSSOListAccountsOutput{ + AccountList: make([]dynamoDBSSOAccountInfo, 0, len(resp.AccountList)), + NextToken: strings.TrimSpace(resp.NextToken), + } + for _, item := range resp.AccountList { + out.AccountList = append(out.AccountList, dynamoDBSSOAccountInfo{ + AccountID: strings.TrimSpace(item.AccountID), + AccountName: strings.TrimSpace(item.AccountName), + EmailAddress: strings.TrimSpace(item.EmailAddress), + }) + } + return out, nil +} + +func (c *dynamoDBSSOHTTPClient) ListAccountRoles(ctx context.Context, params dynamoDBSSOListAccountRolesInput) (dynamoDBSSOListAccountRolesOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOListAccountRolesOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/assignment/roles" + query := url.Values{} + query.Set("account_id", strings.TrimSpace(params.AccountID)) + if params.MaxResults > 0 { + query.Set("max_result", strconv.FormatInt(int64(params.MaxResults), 10)) + } + if token := strings.TrimSpace(params.NextToken); token != "" { + query.Set("next_token", token) + } + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + var resp struct { + RoleList []struct { + RoleName string `json:"roleName"` + AccountID string `json:"accountId"` + } `json:"roleList"` + NextToken string `json:"nextToken"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodGet, + endpoint, + map[string]string{"X-Amz-Sso_bearer_token": strings.TrimSpace(params.AccessToken)}, + nil, + &resp, + "aws sso list-account-roles", + ); err != nil { + return dynamoDBSSOListAccountRolesOutput{}, err + } + out := dynamoDBSSOListAccountRolesOutput{ + RoleList: make([]dynamoDBSSORoleInfo, 0, len(resp.RoleList)), + NextToken: strings.TrimSpace(resp.NextToken), + } + for _, item := range resp.RoleList { + out.RoleList = append(out.RoleList, dynamoDBSSORoleInfo{ + RoleName: strings.TrimSpace(item.RoleName), + AccountID: strings.TrimSpace(item.AccountID), + }) + } + return out, nil +} + +func (c *dynamoDBSSOHTTPClient) GetRoleCredentials(ctx context.Context, params dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOGetRoleCredentialsOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/federation/credentials" + query := url.Values{} + query.Set("account_id", strings.TrimSpace(params.AccountID)) + query.Set("role_name", strings.TrimSpace(params.RoleName)) + if encoded := query.Encode(); encoded != "" { + endpoint = endpoint + "?" + encoded + } + var resp struct { + RoleCredentials *struct { + AccessKeyID string `json:"accessKeyId"` + SecretAccessKey string `json:"secretAccessKey"` + SessionToken string `json:"sessionToken"` + Expiration int64 `json:"expiration"` + } `json:"roleCredentials"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodGet, + endpoint, + map[string]string{"X-Amz-Sso_bearer_token": strings.TrimSpace(params.AccessToken)}, + nil, + &resp, + "aws sso get-role-credentials", + ); err != nil { + return dynamoDBSSOGetRoleCredentialsOutput{}, err + } + out := dynamoDBSSOGetRoleCredentialsOutput{} + if resp.RoleCredentials != nil { + out.RoleCredentials = &dynamoDBSSORoleCredentialsOutput{ + AccessKeyID: strings.TrimSpace(resp.RoleCredentials.AccessKeyID), + SecretAccessKey: strings.TrimSpace(resp.RoleCredentials.SecretAccessKey), + SessionToken: strings.TrimSpace(resp.RoleCredentials.SessionToken), + Expiration: resp.RoleCredentials.Expiration, + } + } + return out, nil +} + +func (c *dynamoDBSSOOIDCHTTPClient) RegisterClient(ctx context.Context, params dynamoDBSSOOIDCRegisterClientInput) (dynamoDBSSOOIDCRegisterClientOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOOIDCRegisterClientOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/client/register" + var resp struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodPost, + endpoint, + nil, + map[string]any{ + "clientName": params.ClientName, + "clientType": params.ClientType, + "grantTypes": params.GrantTypes, + "scopes": params.Scopes, + }, + &resp, + "aws sso register-client", + ); err != nil { + return dynamoDBSSOOIDCRegisterClientOutput{}, err + } + return dynamoDBSSOOIDCRegisterClientOutput{ + ClientID: strings.TrimSpace(resp.ClientID), + ClientSecret: strings.TrimSpace(resp.ClientSecret), + }, nil +} + +func (c *dynamoDBSSOOIDCHTTPClient) StartDeviceAuthorization(ctx context.Context, params dynamoDBSSOOIDCStartDeviceAuthorizationInput) (dynamoDBSSOOIDCStartDeviceAuthorizationOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOOIDCStartDeviceAuthorizationOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/device_authorization" + var resp struct { + DeviceCode string `json:"deviceCode"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete"` + UserCode string `json:"userCode"` + ExpiresIn int32 `json:"expiresIn"` + Interval int32 `json:"interval"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodPost, + endpoint, + nil, + map[string]any{ + "clientId": params.ClientID, + "clientSecret": params.ClientSecret, + "startUrl": params.StartURL, + }, + &resp, + "aws sso start-device-authorization", + ); err != nil { + return dynamoDBSSOOIDCStartDeviceAuthorizationOutput{}, err + } + return dynamoDBSSOOIDCStartDeviceAuthorizationOutput{ + DeviceCode: strings.TrimSpace(resp.DeviceCode), + VerificationURI: strings.TrimSpace(resp.VerificationURI), + VerificationURIComplete: strings.TrimSpace(resp.VerificationURIComplete), + UserCode: strings.TrimSpace(resp.UserCode), + ExpiresIn: resp.ExpiresIn, + Interval: resp.Interval, + }, nil +} + +func (c *dynamoDBSSOOIDCHTTPClient) CreateToken(ctx context.Context, params dynamoDBSSOOIDCCreateTokenInput) (dynamoDBSSOOIDCCreateTokenOutput, error) { + if err := c.validate(); err != nil { + return dynamoDBSSOOIDCCreateTokenOutput{}, err + } + endpoint := strings.TrimRight(c.baseURL, "/") + "/token" + var resp struct { + AccessToken string `json:"accessToken"` + ExpiresIn int32 `json:"expiresIn"` + } + if err := dynamoDBSSOAPIRequest( + ctx, + c.httpClient, + http.MethodPost, + endpoint, + nil, + map[string]any{ + "clientId": params.ClientID, + "clientSecret": params.ClientSecret, + "grantType": params.GrantType, + "deviceCode": params.DeviceCode, + }, + &resp, + "aws sso create-token", + ); err != nil { + return dynamoDBSSOOIDCCreateTokenOutput{}, err + } + return dynamoDBSSOOIDCCreateTokenOutput{ + AccessToken: strings.TrimSpace(resp.AccessToken), + ExpiresIn: resp.ExpiresIn, + }, nil +} + +func (p DataSourcePayload) toDataSource(id string) datasource.DataSource { + return datasource.DataSource{ + ID: id, + Name: strings.TrimSpace(p.Name), + Type: p.Type, + Host: strings.TrimSpace(p.Host), + Port: p.Port, + Username: strings.TrimSpace(p.Username), + Password: p.Password, + Database: strings.TrimSpace(p.Database), + AuthSource: strings.TrimSpace(p.AuthSource), + Options: p.Options, + SecretRefs: datasource.PruneSecretRefs(p.SecretRefs), + } +} + +func validateDataSourcePayload(p DataSourcePayload) error { + if strings.TrimSpace(p.Name) == "" { + return errors.New("name is required") + } + if p.Type == "" { + return errors.New("type is required") + } + switch p.Type { + case datasource.TypeMySQL, datasource.TypePostgreSQL, datasource.TypeMongoDB, datasource.TypeRedis, datasource.TypeElasticsearch, datasource.TypeChromaDB, datasource.TypeDynamoDB, datasource.TypeD1: + default: + return errors.New("unsupported type") + } + if err := datasource.ValidateSecretRefs(p.SecretRefs); err != nil { + return err + } + if p.Type == datasource.TypeMongoDB { + // A uri/hosts-based connection (including a secret-backed options.uri ref) + // supplies addressing out of band, so don't require host/port — and don't + // gate the exemption on host/port being empty, since the form may submit the + // type's default port (27017) for a ref-backed datasource with no host UI. + // An inline options.uri is stripped on save when a password ref shadows it, + // so it only counts as addressing when it will survive. Hosts and a delegated + // options.uri ref are never stripped, so they always satisfy addressing. + inlineURIUsable := hasSQLOptionsURI(p.Options) && !datasource.InlineOptionURIWillBeStripped(p.SecretRefs) + if hasMongoOptionsHosts(p.Options) || inlineURIUsable || datasource.HasResolvableOptionURIRef(p.SecretRefs) { + // allow MongoDB uri/hosts without host/port + } else { + if strings.TrimSpace(p.Host) == "" { + return errors.New("host is required") + } + if p.Port <= 0 { + return errors.New("port is required") + } + } + } else if p.Type == datasource.TypeRedis { + if strings.TrimSpace(p.Host) == "" || p.Port <= 0 { + if !hasRedisOptionsNodes(p.Options) { + if strings.TrimSpace(p.Host) == "" { + return errors.New("host is required") + } + if p.Port <= 0 { + return errors.New("port is required") + } + } + } + } else if p.Type == datasource.TypeDynamoDB { + if !hasDynamoDBRegion(p.Options) { + return errors.New("region is required") + } + } else if p.Type == datasource.TypeD1 { + if err := validateD1Options(p.Options, p.SecretRefs); err != nil { + return err + } + } else if p.Type == datasource.TypeMySQL || p.Type == datasource.TypePostgreSQL { + // An inline options.uri only counts as addressing if it will survive the save. + // When a password ref is present, ClearInlineSecretsForRefs strips the inline + // uri (it shadows the ref), so the persisted record would have no uri and no + // host/port — require host/port or a delegated options.uri ref instead. + inlineURIUsable := hasSQLOptionsURI(p.Options) && !datasource.InlineOptionURIWillBeStripped(p.SecretRefs) + if !inlineURIUsable && !datasource.HasResolvableOptionURIRef(p.SecretRefs) { + if strings.TrimSpace(p.Host) == "" { + return errors.New("host is required") + } + if p.Port <= 0 { + return errors.New("port is required") + } + } + } else { + if strings.TrimSpace(p.Host) == "" { + return errors.New("host is required") + } + if p.Port <= 0 { + return errors.New("port is required") + } + } + if p.Port < 0 { + return errors.New("port must be >= 0") + } + if p.Port < 0 || p.Port > 65535 { + return errors.New("port out of range") + } + return nil +} + +// hasMongoOptionsHosts reports whether options carries an explicit hosts list. +// Unlike an inline options.uri, the hosts list is never stripped on save, so it +// always satisfies MongoDB addressing regardless of any password ref. +func hasMongoOptionsHosts(options map[string]any) bool { + if options == nil { + return false + } + hostsRaw, ok := options["hosts"] + if !ok { + return false + } + switch v := hostsRaw.(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + return true + } + } + case []string: + for _, s := range v { + if strings.TrimSpace(s) != "" { + return true + } + } + } + return false +} + +func hasSQLOptionsURI(options map[string]any) bool { + if options == nil { + return false + } + if uri, ok := options["uri"].(string); ok && strings.TrimSpace(uri) != "" { + return true + } + return false +} + +func hasRedisOptionsNodes(options map[string]any) bool { + if options == nil { + return false + } + nodesRaw, ok := options["nodes"] + if !ok { + return false + } + switch v := nodesRaw.(type) { + case []any: + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + return true + } + } + case []string: + for _, s := range v { + if strings.TrimSpace(s) != "" { + return true + } + } + case string: + return strings.TrimSpace(v) != "" + } + return false +} + +func hasDynamoDBRegion(options map[string]any) bool { + if options == nil { + return false + } + if region, ok := options["region"].(string); ok && strings.TrimSpace(region) != "" { + return true + } + return false +} + +func validateD1Options(options map[string]any, refs map[string]secrets.SecretRef) error { + mode := strings.ToLower(strings.TrimSpace(optionAnyString(options, "mode"))) + databaseID := strings.TrimSpace(optionAnyString(options, "databaseId")) + if databaseID == "" { + return errors.New("databaseId is required for d1") + } + + if mode == "local" { + if strings.TrimSpace(optionAnyString(options, "binding")) == "" { + return errors.New("binding is required for local mode") + } + return nil + } + + accountID := strings.TrimSpace(optionAnyString(options, "accountId")) + if accountID == "" { + return errors.New("accountId is required for d1") + } + + if mode == "" { + if strings.TrimSpace(optionAnyString(options, "databaseName")) == "" { + return errors.New("databaseName is required for d1") + } + return nil + } + if mode != "cloud" { + return errors.New("mode must be local or cloud when provided") + } + + authMode := strings.ToLower(strings.TrimSpace(optionAnyString(options, "authMode"))) + if authMode == "" { + authMode = "wrangler" + } + if authMode != "wrangler" && authMode != "token" { + return errors.New("authMode must be wrangler or token") + } + if authMode == "token" && + strings.TrimSpace(optionAnyString(options, "apiToken")) == "" && + !datasource.HasResolvableOptionRef(refs, "options.apiToken") { + // The token may be delegated to a secret provider (resolved read-only at + // execution time), in which case the inline value is absent by design. + return errors.New("apiToken is required when authMode=token") + } + return nil +} + +func optionAnyString(options map[string]any, key string) string { + if options == nil { + return "" + } + raw, ok := options[key] + if !ok || raw == nil { + return "" + } + switch typed := raw.(type) { + case string: + return strings.TrimSpace(typed) + default: + rendered := strings.TrimSpace(fmt.Sprint(typed)) + if rendered == "" { + return "" + } + return rendered + } +} + +func (a *App) ListDatasources() ([]datasource.DataSource, error) { + items := a.store.List() + out := make([]datasource.DataSource, 0, len(items)) + for _, item := range items { + out = append(out, datasource.RedactDatasource(item)) + } + return out, nil +} + +func (a *App) GetDatasource(id string) (datasource.DataSource, error) { + item, ok := a.store.Get(id) + if !ok { + return datasource.DataSource{}, errors.New("datasource not found") + } + return datasource.RedactDatasource(item), nil +} + +func (a *App) CreateDatasource(payload DataSourcePayload) (datasource.DataSource, error) { + if err := validateDataSourcePayload(payload); err != nil { + return datasource.DataSource{}, err + } + ds := payload.toDataSource("") + if ds.Type == datasource.TypeD1 { + if strings.TrimSpace(ds.ID) == "" { + ds.ID = newDatasourceID() + } + } + createCheck := a.datasourceCreateCheck() + created, err := a.store.CreateChecked(ds, func(input *datasource.DataSource, count int) error { + if createCheck != nil { + if err := createCheck(count); err != nil { + return err + } + } + *input = a.withRedisClusterNodesDiscovered(*input) + if input.Type != datasource.TypeD1 { + next, err := a.externalizeDatasourceSecrets(*input) + if err != nil { + return err + } + *input = next + return nil + } + next, err := a.withD1MetadataPrepared(*input) + if err != nil { + return err + } + next, err = a.externalizeDatasourceSecrets(next) + if err != nil { + return err + } + *input = next + return nil + }) + if err != nil { + return datasource.DataSource{}, err + } + return datasource.RedactDatasource(created), nil +} + +func (a *App) UpdateDatasource(id string, payload DataSourcePayload) (datasource.DataSource, error) { + if err := validateDataSourcePayload(payload); err != nil { + return datasource.DataSource{}, err + } + ds := payload.toDataSource(id) + existing, ok := a.store.Get(id) + if !ok { + return datasource.DataSource{}, errors.New("datasource not found") + } + ds = datasource.RestoreRedactedDatasource(ds, existing) + if ds.Type == datasource.TypeD1 { + ds.Options = d1CarryLegacyDevMetadataOnUpdate(ds.Options, existing.Options) + next, err := a.withD1MetadataPrepared(ds) + if err != nil { + return datasource.DataSource{}, err + } + ds = next + } + ds = a.withRedisClusterNodesDiscovered(ds) + ds, err := a.externalizeDatasourceSecrets(ds) + if err != nil { + return datasource.DataSource{}, err + } + updated, err := a.store.Update(id, ds) + if err != nil { + return datasource.DataSource{}, err + } + return datasource.RedactDatasource(updated), nil +} + +func (a *App) externalizeDatasourceSecrets(ds datasource.DataSource) (datasource.DataSource, error) { + if a == nil || a.datasourceSecrets == nil { + return ds, nil + } + return a.datasourceSecrets.ExternalizeDatasourceSecrets(context.Background(), ds) +} + +// SetDatasourceTrustLevel updates the per-datasource trust level. The four +// recognised values (approval, cautious, trusted, danger) control how far AI +// Chat / MCP / CLI go before asking for explicit approval. Returns the updated +// datasource. Unknown values are coerced to the default (cautious). +func (a *App) SetDatasourceTrustLevel(id string, trustLevel string) (datasource.DataSource, error) { + existing, ok := a.store.Get(id) + if !ok { + return datasource.DataSource{}, errors.New("datasource not found") + } + // Clone options before mutating so we do not race with other readers/writers + // holding a reference to the map returned by store.Get. Drop the legacy + // `dangerous` flag on write so it cannot resurface if a third-party client + // left it in place — MigrateOptions now ignores the legacy key when an + // explicit trustLevel is set, but stripping here makes the persisted shape + // unambiguous too. + opts := make(map[string]any, len(existing.Options)+1) + for k, v := range existing.Options { + if k == datasource.LegacyDangerousOptionKey { + continue + } + opts[k] = v + } + opts[datasource.TrustLevelOptionKey] = string(datasource.NormalizeTrustLevel(trustLevel)) + existing.Options = opts + return a.store.Update(id, existing) +} + +func (a *App) DeleteDatasource(id string) (bool, error) { + if a.toolService == nil { + // Fallback for code paths that bypass the shared service layer; the + // cascade clean-up below is the same one the service runs. + if err := a.store.Delete(id); err != nil { + return false, err + } + if a.redisProtoStore != nil { + if _, err := a.redisProtoStore.DeleteByDatasource(strings.TrimSpace(id)); err != nil && a.errorLog != nil { + a.errorLog.Printf("delete redis protobuf schemas for %s: %v", id, err) + } + } + return true, nil + } + return a.toolService.DeleteDatasource(context.Background(), id) +} + +func (a *App) TestDatasource(id string) (bool, error) { + ds, ok := a.store.Get(id) + if !ok { + return false, errors.New("datasource not found") + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.test_datasource", ds, "", console.ExecuteOptions{}, false) + err := a.manager.TestConnection(ctx, ds) + finishTiming(err) + if err != nil { + return false, err + } + return true, nil +} + +func (a *App) TestDatasourcePayload(payload DataSourcePayload, existingID string) (bool, error) { + if err := validateDataSourcePayload(payload); err != nil { + return false, err + } + ds := payload.toDataSource("") + existingID = strings.TrimSpace(existingID) + var existing datasource.DataSource + haveExisting := false + if existingID != "" { + if found, ok := a.store.Get(existingID); ok { + existing = found + haveExisting = true + ds = datasource.RestoreRedactedDatasource(ds, existing) + } + } + // Reference-only secret binding. A SecretRef may only be resolved against the + // stored datasource that owns it — never against a target the caller supplies in + // this payload. Otherwise a renderer that listed a ref-backed datasource could + // reuse its secretRefs in a new/edited payload pointed at an attacker-controlled + // host, driving the backend to resolve the secret and AUTH outward to that host + // (the agent surface rejects the same pattern outright in Service.TestDatasourcePayload). + // The decision keys off the *restored* refs: RestoreRedactedDatasource re-adds the + // stored ref when the secret is unchanged ([REDACTED]) and drops it when the user + // switches that field back to a typed value, so a real ref here means this very + // connection still delegates to a secret. A switch-to-manual edit carries no real + // ref and is tested with the newly typed credential against the form target. + if datasource.HasRealSecretRefs(ds.SecretRefs) { + // A brand-new datasource cannot be tested with a ref attached: it must be saved + // first so the ref is bound to a concrete, operator-visible target, then tested by id. + if !haveExisting { + return false, errors.New("save the datasource before testing a secret-backed connection") + } + // Only the unchanged stored connection may be resolved here. If the caller edited + // the reference (provider/key/version) or the target (host/database/options), the + // stored secret must not be resolved toward the new, unsaved target — that is the + // exfiltration shape — and testing the stored record would falsely report success + // for settings Save will replace. Require a save first so the new binding is + // committed (then it is tested by id via TestDatasource); otherwise test the + // stored datasource as persisted. + if !secretBackedConnectionMatchesStored(ds, existing) { + return false, errors.New("save the datasource before testing the updated secret-backed connection") + } + ds = existing + } + ctx, finishTiming := a.beginDatasourceTiming(context.Background(), "app.test_datasource_payload", ds, "", console.ExecuteOptions{}, false) + err := a.manager.TestConnection(ctx, ds) + finishTiming(err) + if err != nil { + return false, err + } + return true, nil +} + +// secretBackedConnectionMatchesStored reports whether the restored, secret-backed +// payload describes the same reference and connection target as the stored record. +// Only then may the stored secret be resolved for a Test: an edited reference or +// target must be saved first so the secret is never resolved toward an unverified +// destination and so the test reflects what Save will persist. The comparison is +// conservative — any meaningful connection difference fails it — because a false +// "changed" only asks the operator to save first, while a false "unchanged" would +// resolve a secret toward an unintended target. +func secretBackedConnectionMatchesStored(restored, stored datasource.DataSource) bool { + if restored.Type != stored.Type || + restored.Host != stored.Host || + restored.Port != stored.Port || + strings.TrimSpace(restored.Username) != strings.TrimSpace(stored.Username) || + strings.TrimSpace(restored.Database) != strings.TrimSpace(stored.Database) || + strings.TrimSpace(restored.AuthSource) != strings.TrimSpace(stored.AuthSource) { + return false + } + if !reflect.DeepEqual(restored.SecretRefs, stored.SecretRefs) { + return false + } + return optionsEqual(restored.Options, stored.Options) +} + +// optionsEqual compares two option maps, treating nil and empty as equal so a +// round-tripped payload that drops an empty map does not read as a change. +func optionsEqual(a, b map[string]any) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +func (a *App) withRedisClusterNodesDiscovered(ds datasource.DataSource) datasource.DataSource { + if ds.Type != datasource.TypeRedis { + return ds + } + if hasRedisOptionsNodes(ds.Options) { + return ds + } + if strings.TrimSpace(ds.Host) == "" || ds.Port <= 0 { + return ds + } + if a == nil || a.manager == nil { + return ds + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, err := a.manager.ExecuteInternal(ctx, ds, "CLUSTER NODES", console.ExecuteOptions{}) + if err != nil { + return ds + } + raw, ok := queryResultText(result) + if !ok { + return ds + } + nodes := parseRedisClusterNodes(raw) + if len(nodes) == 0 { + return ds + } + + next := ds + next.Options = copyDatasourceOptions(ds.Options) + next.Options["nodes"] = nodes + return next +} + +func (a *App) withD1MetadataPrepared(ds datasource.DataSource) (datasource.DataSource, error) { + if ds.Type != datasource.TypeD1 { + return ds, nil + } + mode := strings.ToLower(strings.TrimSpace(optionAnyString(ds.Options, "mode"))) + databaseID := strings.TrimSpace(optionAnyString(ds.Options, "databaseId")) + if databaseID == "" { + return datasource.DataSource{}, errors.New("databaseId is required for d1") + } + databaseName := strings.TrimSpace(optionAnyString(ds.Options, "databaseName")) + if databaseName == "" { + databaseName = strings.TrimSpace(ds.Database) + } + if databaseName == "" { + if mode == "local" || mode == "cloud" { + databaseName = strings.TrimSpace(databaseID) + } else { + return datasource.DataSource{}, errors.New("databaseName is required for d1") + } + } + binding := strings.TrimSpace(optionAnyString(ds.Options, "binding")) + if binding == "" { + binding = d1BindingFromDatabaseName(databaseName) + } + if binding == "" { + return datasource.DataSource{}, errors.New("binding is required for d1") + } + devProjectPath, err := d1NormalizeProjectPath(optionAnyString(ds.Options, "devProjectPath")) + if err != nil { + return datasource.DataSource{}, err + } + supportDev := optionAnyBool(ds.Options, "supportDev") && devProjectPath != "" + legacyWranglerConfigPath := strings.TrimSpace(optionAnyString(ds.Options, "wranglerConfigPath")) + previousDatabaseID := strings.TrimSpace(optionAnyString(ds.Options, "previousDatabaseId")) + previousBinding := strings.TrimSpace(optionAnyString(ds.Options, "previousBinding")) + migrationDir := filepath.ToSlash(filepath.Join("migrations", d1MigrationDirName(databaseName, databaseID))) + + next := ds + next.Database = databaseName + next.Options = copyDatasourceOptions(ds.Options) + next.Options["databaseId"] = databaseID + next.Options["databaseName"] = databaseName + next.Options["binding"] = binding + next.Options["supportDev"] = supportDev + if supportDev { + configPath, err := a.ensureD1WranglerConfig(devProjectPath, d1WranglerDatabaseEntry{ + Binding: binding, + DatabaseName: databaseName, + DatabaseID: databaseID, + MigrationsDir: migrationDir, + }, previousDatabaseID, previousBinding) + if err != nil { + return datasource.DataSource{}, err + } + next.Options["devProjectPath"] = devProjectPath + next.Options["wranglerConfigPath"] = configPath + next.Options["migrationsDir"] = migrationDir + } else if mode != "local" { + // New remote-mode datasources should not persist autogenerated wrangler files. + delete(next.Options, "devProjectPath") + if legacyWranglerConfigPath == "" { + delete(next.Options, "wranglerConfigPath") + delete(next.Options, "migrationsDir") + } else { + configPath, err := a.ensureD1WranglerConfig(filepath.Dir(legacyWranglerConfigPath), d1WranglerDatabaseEntry{ + Binding: binding, + DatabaseName: databaseName, + DatabaseID: databaseID, + MigrationsDir: migrationDir, + }, previousDatabaseID, previousBinding) + if err != nil { + if errors.Is(err, errD1DevProjectPathMissing) || errors.Is(err, errD1DevProjectPathNotDir) { + delete(next.Options, "wranglerConfigPath") + delete(next.Options, "migrationsDir") + return next, nil + } + return datasource.DataSource{}, err + } + next.Options["wranglerConfigPath"] = configPath + next.Options["migrationsDir"] = migrationDir + } + } + delete(next.Options, "previousDatabaseId") + delete(next.Options, "previousBinding") + return next, nil +} + +type d1WranglerDatabaseEntry struct { + Binding string + DatabaseName string + DatabaseID string + MigrationsDir string +} + +var ( + errD1DevProjectPathMissing = errors.New("devProjectPath does not exist") + errD1DevProjectPathNotDir = errors.New("devProjectPath must be a directory") +) + +func d1MigrationDirName(databaseName, databaseID string) string { + base := d1NormalizeMigrationSegment(databaseName) + if base == "" { + base = "datasource" + } + + identifier := d1NormalizeMigrationSegment(databaseID) + if identifier == "" || base == identifier { + return base + } + return base + "-" + identifier +} + +func d1NormalizeMigrationSegment(value string) string { + trimmed := strings.TrimSpace(strings.ToLower(value)) + if trimmed == "" { + return "" + } + re := regexp.MustCompile(`[^a-z0-9._-]+`) + normalized := re.ReplaceAllString(trimmed, "-") + return strings.Trim(normalized, "-._") +} + +func d1CarryLegacyDevMetadataOnUpdate(nextOptions, existingOptions map[string]any) map[string]any { + merged := copyDatasourceOptions(nextOptions) + if previousDatabaseID := strings.TrimSpace(optionAnyString(existingOptions, "databaseId")); previousDatabaseID != "" { + merged["previousDatabaseId"] = previousDatabaseID + } + if previousBinding := strings.TrimSpace(optionAnyString(existingOptions, "binding")); previousBinding != "" { + merged["previousBinding"] = previousBinding + } + if !d1IsLegacyDevDatasource(existingOptions) { + return merged + } + if strings.TrimSpace(optionAnyString(nextOptions, "wranglerConfigPath")) != "" { + return merged + } + if strings.ToLower(strings.TrimSpace(optionAnyString(nextOptions, "mode"))) == "local" { + return merged + } + if _, hasSupportDevOption := nextOptions["supportDev"]; hasSupportDevOption { + // Respect explicit supportDev updates instead of restoring legacy dev metadata. + return merged + } + legacyWrangler := strings.TrimSpace(optionAnyString(existingOptions, "wranglerConfigPath")) + if legacyWrangler == "" { + return merged + } + merged["wranglerConfigPath"] = legacyWrangler + if legacyMigrationsDir := strings.TrimSpace(optionAnyString(existingOptions, "migrationsDir")); legacyMigrationsDir != "" { + merged["migrationsDir"] = legacyMigrationsDir + } + return merged +} + +func d1IsLegacyDevDatasource(options map[string]any) bool { + if strings.TrimSpace(optionAnyString(options, "wranglerConfigPath")) == "" { + return false + } + if optionAnyBool(options, "supportDev") { + return false + } + if strings.TrimSpace(optionAnyString(options, "devProjectPath")) != "" { + return false + } + return true +} + +func newDatasourceID() string { + now := time.Now().UTC().UnixNano() + return fmt.Sprintf("ds_%x", now) +} + +func (a *App) ensureD1WranglerConfig(projectPath string, entry d1WranglerDatabaseEntry, previousDatabaseID, previousBinding string) (string, error) { + trimmedPath := strings.TrimSpace(projectPath) + if trimmedPath == "" { + return "", errors.New("devProjectPath is required when supportDev is enabled") + } + info, err := os.Stat(trimmedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", errD1DevProjectPathMissing + } + return "", err + } + if !info.IsDir() { + return "", errD1DevProjectPathNotDir + } + + configPath := filepath.Join(trimmedPath, "wrangler.toml") + raw, readErr := os.ReadFile(configPath) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return "", readErr + } + content := string(raw) + if next, changed := d1WranglerUpsertDatabaseEntryWithFallback(content, entry, previousDatabaseID, previousBinding); changed { + content = next + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + return "", err + } + } + return configPath, nil +} + +func d1WranglerUpsertDatabaseEntry(content string, entry d1WranglerDatabaseEntry) (string, bool) { + return d1WranglerUpsertDatabaseEntryWithFallback(content, entry, "", "") +} + +func d1WranglerUpsertDatabaseEntryWithFallback(content string, entry d1WranglerDatabaseEntry, previousDatabaseID, previousBinding string) (string, bool) { + attempts := []struct { + key string + value string + }{ + {key: "database_id", value: entry.DatabaseID}, + {key: "database_id", value: previousDatabaseID}, + {key: "binding", value: entry.Binding}, + {key: "binding", value: previousBinding}, + } + seen := make(map[string]struct{}, len(attempts)) + for _, attempt := range attempts { + trimmedValue := strings.TrimSpace(attempt.value) + if trimmedValue == "" { + continue + } + signature := attempt.key + ":" + trimmedValue + if _, ok := seen[signature]; ok { + continue + } + seen[signature] = struct{}{} + if replaced, ok := d1WranglerReplaceDatabaseEntryByKey(content, entry, attempt.key, trimmedValue); ok { + return replaced, replaced != content + } + } + next := d1WranglerAppendDatabaseEntry(content, entry) + return next, next != content +} + +func d1WranglerReplaceDatabaseEntryByKey(content string, entry d1WranglerDatabaseEntry, key, value string) (string, bool) { + trimmedValue := strings.TrimSpace(value) + if trimmedValue == "" { + return "", false + } + return d1WranglerReplaceDatabaseEntry(content, entry, func(block string) bool { + return d1WranglerBlockHasTomlString(block, key, trimmedValue) + }) +} + +func d1WranglerReplaceDatabaseEntry(content string, entry d1WranglerDatabaseEntry, shouldReplace func(block string) bool) (string, bool) { + if shouldReplace == nil { + return "", false + } + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return "", false + } + out := make([]string, 0, len(lines)) + replaced := false + for i := 0; i < len(lines); { + if strings.TrimSpace(lines[i]) != "[[d1_databases]]" { + out = append(out, lines[i]) + i++ + continue + } + j := i + 1 + for j < len(lines) { + trimmed := strings.TrimSpace(lines[j]) + if strings.HasPrefix(trimmed, "[") { + break + } + j++ + } + block := strings.Join(lines[i:j], "\n") + if !replaced && shouldReplace(block) { + replacement := strings.TrimRight(d1WranglerToml(entry), "\n") + out = append(out, strings.Split(replacement, "\n")...) + replaced = true + } else { + out = append(out, lines[i:j]...) + } + i = j + } + if !replaced { + return "", false + } + updated := strings.TrimRight(strings.Join(out, "\n"), "\n") + return updated + "\n", true +} + +func d1WranglerBlockHasTomlString(block, key, value string) bool { + trimmedKey := strings.TrimSpace(key) + trimmedValue := strings.TrimSpace(value) + if trimmedKey == "" || trimmedValue == "" { + return false + } + pattern := fmt.Sprintf(`(?m)^\s*%s\s*=\s*%s\s*$`, regexp.QuoteMeta(trimmedKey), regexp.QuoteMeta(d1TomlString(trimmedValue))) + return regexp.MustCompile(pattern).MatchString(block) +} + +func d1WranglerAppendDatabaseEntry(content string, entry d1WranglerDatabaseEntry) string { + block := d1WranglerToml(entry) + trimmed := strings.TrimRight(content, "\n") + if strings.TrimSpace(trimmed) == "" { + return block + } + return trimmed + "\n\n" + strings.TrimRight(block, "\n") + "\n" +} + +func d1WranglerToml(entry d1WranglerDatabaseEntry) string { + lines := []string{ + "[[d1_databases]]", + `binding = ` + d1TomlString(entry.Binding), + `database_name = ` + d1TomlString(entry.DatabaseName), + `database_id = ` + d1TomlString(entry.DatabaseID), + } + if strings.TrimSpace(entry.MigrationsDir) != "" { + lines = append(lines, `migrations_dir = `+d1TomlString(entry.MigrationsDir)) + } + lines = append(lines, "") + return strings.Join(lines, "\n") +} + +func d1NormalizeProjectPath(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + if strings.HasPrefix(trimmed, "~/") || trimmed == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if trimmed == "~" { + trimmed = home + } else { + trimmed = filepath.Join(home, strings.TrimPrefix(trimmed, "~/")) + } + } + abs, err := filepath.Abs(trimmed) + if err != nil { + return "", err + } + return abs, nil +} + +func d1BindingFromDatabaseName(databaseName string) string { + trimmed := strings.TrimSpace(strings.ToLower(databaseName)) + re := regexp.MustCompile(`[^a-z0-9_]+`) + binding := re.ReplaceAllString(trimmed, "_") + binding = strings.Trim(binding, "_") + if binding == "" { + return "db" + } + if binding[0] >= '0' && binding[0] <= '9' { + return "db_" + binding + } + return binding +} + +func optionAnyBool(options map[string]any, key string) bool { + if options == nil { + return false + } + raw, ok := options[key] + if !ok || raw == nil { + return false + } + switch typed := raw.(type) { + case bool: + return typed + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "1", "true", "yes", "on": + return true + default: + return false + } + case int: + return typed != 0 + case int8: + return typed != 0 + case int16: + return typed != 0 + case int32: + return typed != 0 + case int64: + return typed != 0 + case uint: + return typed != 0 + case uint8: + return typed != 0 + case uint16: + return typed != 0 + case uint32: + return typed != 0 + case uint64: + return typed != 0 + case float32: + return typed != 0 + case float64: + return typed != 0 + default: + return false + } +} + +func d1DatasourceSupportsDev(options map[string]any) bool { + if strings.ToLower(strings.TrimSpace(optionAnyString(options, "mode"))) == "local" { + return true + } + if strings.TrimSpace(optionAnyString(options, "wranglerConfigPath")) != "" { + return true + } + if !optionAnyBool(options, "supportDev") { + return false + } + if strings.TrimSpace(optionAnyString(options, "devProjectPath")) == "" { + return false + } + return true +} + +func d1TomlString(value string) string { + escaped := strings.ReplaceAll(value, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return `"` + escaped + `"` +} + +func (a *App) DynamoDBSSOListProfiles(configPath string) ([]DynamoDBSSOProfile, error) { + resolvedPath, err := awsConfigPath(configPath) + if err != nil { + return nil, err + } + raw, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("read aws config: %w", err) + } + profiles := awsProfilesFromConfig(string(raw)) + if len(profiles) == 0 { + return nil, errors.New("no aws profiles found in ~/.aws/config") + } + return profiles, nil +} + +func (a *App) DynamoDBSSOLogin(profile string) (DynamoDBSSOLoginResult, error) { + configPath, err := awsConfigPath("") + if err != nil { + return DynamoDBSSOLoginResult{}, err + } + raw, err := os.ReadFile(configPath) + if err != nil { + return DynamoDBSSOLoginResult{}, fmt.Errorf("read aws config: %w", err) + } + profileConfig, err := awsResolveProfileConfig(string(raw), profile) + if err != nil { + return DynamoDBSSOLoginResult{}, err + } + if strings.TrimSpace(profileConfig.Name) == "" { + return DynamoDBSSOLoginResult{}, errors.New("aws profile is required") + } + ssoRegion := strings.TrimSpace(profileConfig.SSORegion) + if ssoRegion == "" { + ssoRegion = strings.TrimSpace(profileConfig.Region) + } + if ssoRegion == "" { + return DynamoDBSSOLoginResult{}, errors.New("sso_region or region is required for AWS SSO profile") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.dynamoDBSSOEnsureAccessToken(ctx, profileConfig, ssoRegion) + if err != nil { + return DynamoDBSSOLoginResult{}, err + } + + return DynamoDBSSOLoginResult{ + AccessToken: token.AccessToken, + ExpiresAt: token.ExpiresAt, + }, nil +} + +func (a *App) DynamoDBSSOOAuthAuthorize(profile, region, configPath string) (DynamoDBSSOOAuthResult, error) { + resolvedPath, err := awsConfigPath(configPath) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + raw, err := os.ReadFile(resolvedPath) + if err != nil { + return DynamoDBSSOOAuthResult{}, fmt.Errorf("read aws config: %w", err) + } + profileConfig, err := awsResolveProfileConfig(string(raw), profile) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + + selectedRegion := strings.TrimSpace(region) + if selectedRegion == "" { + selectedRegion = strings.TrimSpace(profileConfig.Region) + } + if selectedRegion == "" { + selectedRegion = strings.TrimSpace(profileConfig.SSORegion) + } + if selectedRegion == "" { + return DynamoDBSSOOAuthResult{}, errors.New("region is required") + } + ssoRegion := strings.TrimSpace(profileConfig.SSORegion) + if ssoRegion == "" { + ssoRegion = selectedRegion + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := a.dynamoDBSSOEnsureAccessToken(ctx, profileConfig, ssoRegion) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + + accountID := strings.TrimSpace(profileConfig.AccountID) + if accountID == "" { + accounts, err := a.DynamoDBSSOListAccounts(token.AccessToken, ssoRegion) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + if len(accounts) != 1 { + return DynamoDBSSOOAuthResult{}, errors.New("unable to resolve AWS account automatically; set sso_account_id in the selected profile") + } + accountID = strings.TrimSpace(accounts[0].AccountID) + } + if accountID == "" { + return DynamoDBSSOOAuthResult{}, errors.New("accountId is required") + } + + roleName := strings.TrimSpace(profileConfig.RoleName) + if roleName == "" { + roles, err := a.DynamoDBSSOListAccountRoles(accountID, token.AccessToken, ssoRegion) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + if len(roles) != 1 { + return DynamoDBSSOOAuthResult{}, errors.New("unable to resolve AWS role automatically; set sso_role_name in the selected profile") + } + roleName = strings.TrimSpace(roles[0].RoleName) + } + if roleName == "" { + return DynamoDBSSOOAuthResult{}, errors.New("roleName is required") + } + + credentials, err := a.DynamoDBSSOGetRoleCredentials(accountID, roleName, token.AccessToken, ssoRegion) + if err != nil { + return DynamoDBSSOOAuthResult{}, err + } + + return DynamoDBSSOOAuthResult{ + Profile: profileConfig.Name, + Region: selectedRegion, + AccountID: accountID, + RoleName: roleName, + AccessKeyID: strings.TrimSpace(credentials.AccessKeyID), + SecretAccessKey: strings.TrimSpace(credentials.SecretAccessKey), + SessionToken: strings.TrimSpace(credentials.SessionToken), + Expiration: credentials.Expiration, + }, nil +} + +func (a *App) DynamoDBSSOListAccounts(accessToken, region string) ([]DynamoDBSSOAccount, error) { + accessToken = strings.TrimSpace(accessToken) + region = strings.TrimSpace(region) + if accessToken == "" { + return nil, errors.New("accessToken is required") + } + if region == "" { + return nil, errors.New("region is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + client := newDynamoDBSSOClient(region, a.resolvedHTTPClient()) + out := make([]DynamoDBSSOAccount, 0, 8) + nextToken := "" + for { + resp, err := client.ListAccounts(ctx, dynamoDBSSOListAccountsInput{ + AccessToken: accessToken, + NextToken: nextToken, + MaxResults: 100, + }) + if err != nil { + return nil, fmt.Errorf("aws sso list-accounts failed: %w", err) + } + for _, item := range resp.AccountList { + accountID := strings.TrimSpace(item.AccountID) + if accountID == "" { + continue + } + accountName := strings.TrimSpace(item.AccountName) + if accountName == "" { + accountName = accountID + } + out = append(out, DynamoDBSSOAccount{ + AccountID: accountID, + AccountName: accountName, + EmailAddress: strings.TrimSpace(item.EmailAddress), + }) + } + if strings.TrimSpace(resp.NextToken) == "" { + break + } + nextToken = strings.TrimSpace(resp.NextToken) + } + sort.Slice(out, func(i, j int) bool { + left := strings.ToLower(strings.TrimSpace(out[i].AccountName)) + right := strings.ToLower(strings.TrimSpace(out[j].AccountName)) + if left == right { + return out[i].AccountID < out[j].AccountID + } + return left < right + }) + if len(out) == 0 { + return nil, errors.New("aws sso list-accounts returned no accounts") + } + return out, nil +} + +func (a *App) DynamoDBSSOListAccountRoles(accountID, accessToken, region string) ([]DynamoDBSSORole, error) { + accountID = strings.TrimSpace(accountID) + accessToken = strings.TrimSpace(accessToken) + region = strings.TrimSpace(region) + if accountID == "" { + return nil, errors.New("accountId is required") + } + if accessToken == "" { + return nil, errors.New("accessToken is required") + } + if region == "" { + return nil, errors.New("region is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + client := newDynamoDBSSOClient(region, a.resolvedHTTPClient()) + out := make([]DynamoDBSSORole, 0, 8) + nextToken := "" + for { + resp, err := client.ListAccountRoles(ctx, dynamoDBSSOListAccountRolesInput{ + AccountID: accountID, + AccessToken: accessToken, + NextToken: nextToken, + MaxResults: 100, + }) + if err != nil { + return nil, fmt.Errorf("aws sso list-account-roles failed: %w", err) + } + for _, item := range resp.RoleList { + roleName := strings.TrimSpace(item.RoleName) + if roleName == "" { + continue + } + roleAccountID := strings.TrimSpace(item.AccountID) + if roleAccountID == "" { + roleAccountID = accountID + } + out = append(out, DynamoDBSSORole{ + RoleName: roleName, + AccountID: roleAccountID, + }) + } + if strings.TrimSpace(resp.NextToken) == "" { + break + } + nextToken = strings.TrimSpace(resp.NextToken) + } + sort.Slice(out, func(i, j int) bool { + left := strings.ToLower(strings.TrimSpace(out[i].RoleName)) + right := strings.ToLower(strings.TrimSpace(out[j].RoleName)) + if left == right { + return out[i].AccountID < out[j].AccountID + } + return left < right + }) + if len(out) == 0 { + return nil, errors.New("aws sso list-account-roles returned no roles") + } + return out, nil +} + +func (a *App) DynamoDBSSOGetRoleCredentials(accountID, roleName, accessToken, region string) (DynamoDBSSORoleCredentials, error) { + accountID = strings.TrimSpace(accountID) + roleName = strings.TrimSpace(roleName) + accessToken = strings.TrimSpace(accessToken) + region = strings.TrimSpace(region) + if accountID == "" { + return DynamoDBSSORoleCredentials{}, errors.New("accountId is required") + } + if roleName == "" { + return DynamoDBSSORoleCredentials{}, errors.New("roleName is required") + } + if accessToken == "" { + return DynamoDBSSORoleCredentials{}, errors.New("accessToken is required") + } + if region == "" { + return DynamoDBSSORoleCredentials{}, errors.New("region is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + client := newDynamoDBSSOClient(region, a.resolvedHTTPClient()) + resp, err := client.GetRoleCredentials(ctx, dynamoDBSSOGetRoleCredentialsInput{ + AccountID: accountID, + RoleName: roleName, + AccessToken: accessToken, + }) + if err != nil { + return DynamoDBSSORoleCredentials{}, fmt.Errorf("aws sso get-role-credentials failed: %w", err) + } + if resp.RoleCredentials == nil { + return DynamoDBSSORoleCredentials{}, errors.New("aws sso get-role-credentials returned empty credentials") + } + creds := DynamoDBSSORoleCredentials{ + AccessKeyID: strings.TrimSpace(resp.RoleCredentials.AccessKeyID), + SecretAccessKey: strings.TrimSpace(resp.RoleCredentials.SecretAccessKey), + SessionToken: strings.TrimSpace(resp.RoleCredentials.SessionToken), + Expiration: resp.RoleCredentials.Expiration, + } + if creds.AccessKeyID == "" || creds.SecretAccessKey == "" || creds.SessionToken == "" { + return DynamoDBSSORoleCredentials{}, errors.New("aws sso get-role-credentials returned incomplete credentials") + } + return creds, nil +} + +func (a *App) resolvedHTTPClient() *http.Client { + if a != nil && a.httpClient != nil { + return a.httpClient + } + return &http.Client{Timeout: 20 * time.Second} +} + +func (a *App) dynamoDBSSOEnsureAccessToken(ctx context.Context, profileConfig awsProfileConfig, oidcRegion string) (awsSSOCacheToken, error) { + if cacheDir, err := awsSSOCacheDir(); err == nil { + if token, tokenErr := awsResolveSSOCacheToken(cacheDir, profileConfig.StartURL); tokenErr == nil { + return token, nil + } + } + return a.dynamoDBSSOAuthorizeWithDeviceCode(ctx, profileConfig, oidcRegion) +} + +func (a *App) dynamoDBSSOAuthorizeWithDeviceCode(ctx context.Context, profileConfig awsProfileConfig, oidcRegion string) (awsSSOCacheToken, error) { + startURL := strings.TrimSpace(profileConfig.StartURL) + if startURL == "" { + return awsSSOCacheToken{}, errors.New("sso_start_url is required for AWS SSO profile") + } + oidcRegion = strings.TrimSpace(oidcRegion) + if oidcRegion == "" { + return awsSSOCacheToken{}, errors.New("sso_region or region is required for AWS SSO profile") + } + if ctx == nil { + ctx = context.Background() + } + clientName := strings.TrimSpace(profileConfig.Name) + if clientName == "" { + clientName = "futrixdata-dynamodb-sso" + } + + oidcClient := newDynamoDBSSOOIDCClient(oidcRegion, a.resolvedHTTPClient()) + registerResp, err := oidcClient.RegisterClient(ctx, dynamoDBSSOOIDCRegisterClientInput{ + ClientName: clientName, + ClientType: "public", + GrantTypes: []string{"urn:ietf:params:oauth:grant-type:device_code"}, + Scopes: []string{"sso:account:access"}, + }) + if err != nil { + return awsSSOCacheToken{}, fmt.Errorf("aws sso register-client failed: %w", err) + } + clientID := strings.TrimSpace(registerResp.ClientID) + clientSecret := strings.TrimSpace(registerResp.ClientSecret) + if clientID == "" || clientSecret == "" { + return awsSSOCacheToken{}, errors.New("aws sso register-client returned incomplete client credentials") + } + + authorizeResp, err := oidcClient.StartDeviceAuthorization(ctx, dynamoDBSSOOIDCStartDeviceAuthorizationInput{ + ClientID: clientID, + ClientSecret: clientSecret, + StartURL: startURL, + }) + if err != nil { + return awsSSOCacheToken{}, fmt.Errorf("aws sso start-device-authorization failed: %w", err) + } + deviceCode := strings.TrimSpace(authorizeResp.DeviceCode) + if deviceCode == "" { + return awsSSOCacheToken{}, errors.New("aws sso start-device-authorization returned empty device code") + } + verifyURL := strings.TrimSpace(authorizeResp.VerificationURIComplete) + if verifyURL == "" { + verifyURL = strings.TrimSpace(authorizeResp.VerificationURI) + } + if verifyURL == "" { + return awsSSOCacheToken{}, errors.New("aws sso start-device-authorization returned empty verification URL") + } + if err := openDynamoDBSSOVerificationURL(verifyURL); err != nil { + userCode := strings.TrimSpace(authorizeResp.UserCode) + if userCode == "" { + return awsSSOCacheToken{}, fmt.Errorf("open AWS SSO verification URL failed: %w", err) + } + return awsSSOCacheToken{}, fmt.Errorf("open AWS SSO verification URL failed: %w; manually open %s and enter code %s", err, verifyURL, userCode) + } + + interval := time.Duration(authorizeResp.Interval) * time.Second + if interval <= 0 { + interval = 2 * time.Second + } + remaining := time.Duration(authorizeResp.ExpiresIn) * time.Second + if remaining <= 0 { + remaining = 5 * time.Minute + } + pollCtx, cancel := context.WithTimeout(ctx, remaining) + defer cancel() + + for { + tokenResp, tokenErr := oidcClient.CreateToken(pollCtx, dynamoDBSSOOIDCCreateTokenInput{ + ClientID: clientID, + ClientSecret: clientSecret, + GrantType: "urn:ietf:params:oauth:grant-type:device_code", + DeviceCode: deviceCode, + }) + if tokenErr == nil { + accessToken := strings.TrimSpace(tokenResp.AccessToken) + if accessToken == "" { + return awsSSOCacheToken{}, errors.New("aws sso create-token returned empty access token") + } + expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second + if expiresIn <= 0 { + expiresIn = time.Hour + } + expiresAt := time.Now().UTC().Add(expiresIn) + return awsSSOCacheToken{ + AccessToken: accessToken, + ExpiresAt: expiresAt.Format(time.RFC3339), + ExpiresTime: expiresAt, + }, nil + } + + var apiErr *dynamoDBSSOAPIError + if errors.As(tokenErr, &apiErr) && (apiErr.hasCode("AuthorizationPendingException") || apiErr.hasCode("authorization_pending")) { + if waitErr := waitDynamoDBSSOPollInterval(pollCtx, interval); waitErr != nil { + return awsSSOCacheToken{}, fmt.Errorf("aws sso authorization timed out: %w", waitErr) + } + continue + } + + if errors.As(tokenErr, &apiErr) && (apiErr.hasCode("SlowDownException") || apiErr.hasCode("slow_down")) { + interval += time.Second + if waitErr := waitDynamoDBSSOPollInterval(pollCtx, interval); waitErr != nil { + return awsSSOCacheToken{}, fmt.Errorf("aws sso authorization timed out: %w", waitErr) + } + continue + } + + if errors.As(tokenErr, &apiErr) && (apiErr.hasCode("AccessDeniedException") || apiErr.hasCode("access_denied")) { + return awsSSOCacheToken{}, errors.New("aws sso authorization denied by user") + } + + if errors.As(tokenErr, &apiErr) && (apiErr.hasCode("ExpiredTokenException") || apiErr.hasCode("expired_token")) { + return awsSSOCacheToken{}, errors.New("aws sso device authorization expired; retry OAuth authorization") + } + + if errors.Is(tokenErr, context.DeadlineExceeded) || errors.Is(tokenErr, context.Canceled) { + return awsSSOCacheToken{}, fmt.Errorf("aws sso authorization timed out: %w", tokenErr) + } + return awsSSOCacheToken{}, fmt.Errorf("aws sso create-token failed: %w", tokenErr) + } +} + +type awsProfileConfig struct { + Name string + Region string + SSORegion string + StartURL string + AccountID string + RoleName string +} + +type awsSSOCacheToken struct { + AccessToken string + ExpiresAt string + ExpiresTime time.Time +} + +func awsConfigPath(configPath string) (string, error) { + if explicit := strings.TrimSpace(configPath); explicit != "" { + return explicit, nil + } + if envPath := strings.TrimSpace(os.Getenv("AWS_CONFIG_FILE")); envPath != "" { + return envPath, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".aws", "config"), nil +} + +func awsSSOCacheDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".aws", "sso", "cache"), nil +} + +func awsProfilesFromConfig(content string) []DynamoDBSSOProfile { + parsed := awsParseProfileConfigs(content) + out := make([]DynamoDBSSOProfile, 0, len(parsed)) + for _, item := range parsed { + out = append(out, DynamoDBSSOProfile{ + Name: item.Name, + Region: item.Region, + SSORegion: item.SSORegion, + StartURL: item.StartURL, + AccountID: item.AccountID, + RoleName: item.RoleName, + }) + } + return out +} + +func awsResolveProfileConfig(content, profile string) (awsProfileConfig, error) { + profiles := awsParseProfileConfigs(content) + if len(profiles) == 0 { + return awsProfileConfig{}, errors.New("no aws profiles found in ~/.aws/config") + } + trimmedProfile := strings.TrimSpace(profile) + if trimmedProfile == "" { + for _, item := range profiles { + if item.Name == "default" { + trimmedProfile = "default" + break + } + } + } + if trimmedProfile == "" && len(profiles) == 1 { + return profiles[0], nil + } + if trimmedProfile == "" { + return awsProfileConfig{}, errors.New("aws profile is required") + } + for _, item := range profiles { + if item.Name == trimmedProfile { + return item, nil + } + } + return awsProfileConfig{}, fmt.Errorf("aws profile not found: %s", trimmedProfile) +} + +func awsParseProfileConfigs(content string) []awsProfileConfig { + type profileEntry struct { + name string + region string + ssoRegion string + start string + accountID string + roleName string + session string + } + type sessionEntry struct { + name string + ssoRegion string + start string + } + entries := map[string]*profileEntry{} + sessions := map[string]*sessionEntry{} + order := make([]string, 0, 8) + currentProfile := "" + currentSession := "" + + lines := strings.Split(content, "\n") + for _, rawLine := range lines { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "["), "]")) + switch { + case section == "default": + currentProfile = "default" + currentSession = "" + case strings.HasPrefix(section, "profile "): + currentProfile = strings.TrimSpace(strings.TrimPrefix(section, "profile ")) + currentSession = "" + case strings.HasPrefix(section, "sso-session "): + currentProfile = "" + currentSession = strings.TrimSpace(strings.TrimPrefix(section, "sso-session ")) + default: + currentProfile = "" + currentSession = "" + } + if currentProfile != "" { + if _, exists := entries[currentProfile]; !exists { + entries[currentProfile] = &profileEntry{name: currentProfile} + order = append(order, currentProfile) + } + } else if currentSession != "" { + if _, exists := sessions[currentSession]; !exists { + sessions[currentSession] = &sessionEntry{name: currentSession} + } + } + continue + } + if currentProfile == "" && currentSession == "" { + continue + } + sep := strings.Index(line, "=") + if sep <= 0 { + continue + } + key := strings.ToLower(strings.TrimSpace(line[:sep])) + value := strings.TrimSpace(line[sep+1:]) + value = strings.Trim(value, `"`) + if currentProfile != "" { + current := entries[currentProfile] + if current == nil { + continue + } + switch key { + case "region": + current.region = value + case "sso_region": + current.ssoRegion = value + case "sso_start_url": + current.start = value + case "sso_account_id": + current.accountID = value + case "sso_role_name": + current.roleName = value + case "sso_session": + current.session = value + } + continue + } + current := sessions[currentSession] + if current == nil { + continue + } + switch key { + case "sso_region": + current.ssoRegion = value + case "sso_start_url": + current.start = value + } + } + + out := make([]awsProfileConfig, 0, len(order)) + for _, name := range order { + entry := entries[name] + if entry == nil { + continue + } + if strings.TrimSpace(entry.name) == "" { + continue + } + if sessionName := strings.TrimSpace(entry.session); sessionName != "" { + if session := sessions[sessionName]; session != nil { + if strings.TrimSpace(entry.start) == "" { + entry.start = strings.TrimSpace(session.start) + } + if strings.TrimSpace(entry.ssoRegion) == "" { + entry.ssoRegion = strings.TrimSpace(session.ssoRegion) + } + } + } + out = append(out, awsProfileConfig{ + Name: strings.TrimSpace(entry.name), + Region: strings.TrimSpace(entry.region), + SSORegion: strings.TrimSpace(entry.ssoRegion), + StartURL: strings.TrimSpace(entry.start), + AccountID: strings.TrimSpace(entry.accountID), + RoleName: strings.TrimSpace(entry.roleName), + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Name == "default" { + return true + } + if out[j].Name == "default" { + return false + } + left := strings.ToLower(strings.TrimSpace(out[i].Name)) + right := strings.ToLower(strings.TrimSpace(out[j].Name)) + return left < right + }) + return out +} + +func awsResolveSSOCacheToken(cacheDir, startURL string) (awsSSOCacheToken, error) { + entries, err := os.ReadDir(cacheDir) + if err != nil { + return awsSSOCacheToken{}, fmt.Errorf("read aws sso cache directory: %w", err) + } + normalizedStartURL := strings.TrimSpace(startURL) + now := time.Now().UTC() + + var best awsSSOCacheToken + var matched bool + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := strings.ToLower(strings.TrimSpace(entry.Name())) + if !strings.HasSuffix(name, ".json") { + continue + } + path := filepath.Join(cacheDir, entry.Name()) + raw, err := os.ReadFile(path) + if err != nil { + continue + } + var payload struct { + StartURL string `json:"startUrl"` + AccessToken string `json:"accessToken"` + ExpiresAt string `json:"expiresAt"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + continue + } + token := strings.TrimSpace(payload.AccessToken) + expiresAtRaw := strings.TrimSpace(payload.ExpiresAt) + if token == "" || expiresAtRaw == "" { + continue + } + if normalizedStartURL != "" && strings.TrimSpace(payload.StartURL) != normalizedStartURL { + continue + } + expiresAt, err := awsParseSSOExpiresAt(expiresAtRaw) + if err != nil { + continue + } + if expiresAt.Before(now) { + continue + } + if !matched || expiresAt.After(best.ExpiresTime) { + matched = true + best = awsSSOCacheToken{ + AccessToken: token, + ExpiresAt: expiresAtRaw, + ExpiresTime: expiresAt, + } + } + } + if !matched { + if normalizedStartURL != "" { + return awsSSOCacheToken{}, errors.New("no valid aws sso access token found for selected profile") + } + return awsSSOCacheToken{}, errors.New("no valid aws sso access token found") + } + return best, nil +} + +func awsParseSSOExpiresAt(raw string) (time.Time, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return time.Time{}, errors.New("empty expiresAt") + } + layouts := []string{ + time.RFC3339, + "2006-01-02T15:04:05Z0700", + "2006-01-02T15:04:05-0700", + "2006-01-02T15:04:05UTC", + } + for _, layout := range layouts { + parsed, err := time.Parse(layout, trimmed) + if err == nil { + return parsed.UTC(), nil + } + } + if strings.HasSuffix(trimmed, "UTC") { + normalized := strings.TrimSuffix(trimmed, "UTC") + "Z" + parsed, err := time.Parse(time.RFC3339, normalized) + if err == nil { + return parsed.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("invalid expiresAt: %s", trimmed) +} + +func (a *App) D1OAuthLogin() (D1OAuthSession, error) { + return a.d1OAuthLogin(false) +} + +func (a *App) D1OAuthReLogin() (D1OAuthSession, error) { + return a.d1OAuthLogin(true) +} + +func (a *App) D1IsWranglerInstalled() bool { + return d1WranglerInstalled() +} + +func (a *App) d1OAuthLogin(forceBrowserLogin bool) (D1OAuthSession, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + base := []string{"npx", "wrangler"} + + if !forceBrowserLogin { + // Reuse the existing session first so valid tokens do not trigger browser login again. + session, err := a.d1ResolveOAuthSession(ctx, base) + if err == nil { + return session, nil + } + } + + loginCommand := append([]string{}, base...) + loginCommand = append(loginCommand, "login") + if _, loginErr := a.runAppCommand(ctx, loginCommand); loginErr != nil { + lower := strings.ToLower(loginErr.Error()) + if !strings.Contains(lower, "already logged in") { + return D1OAuthSession{}, loginErr + } + } + + return a.d1ResolveOAuthSession(ctx, base) +} + +func (a *App) d1ResolveOAuthSession(ctx context.Context, baseCommand []string) (D1OAuthSession, error) { + token, err := a.d1ResolveWranglerToken(ctx, baseCommand) + if err != nil { + return D1OAuthSession{}, err + } + accounts, accountID, err := a.d1ResolveWranglerAccounts(ctx, baseCommand) + if err != nil { + return D1OAuthSession{}, err + } + return D1OAuthSession{ + Accounts: accounts, + AccountID: accountID, + Token: token, + }, nil +} + +func (a *App) D1ListCloudDatabases(accountID, token string) ([]D1CloudDatabase, error) { + accountID = strings.TrimSpace(accountID) + token = strings.TrimSpace(token) + if accountID == "" { + return nil, errors.New("accountId is required") + } + if token == "" { + return nil, errors.New("token is required") + } + return a.d1ListCloudDatabases(context.Background(), accountID, token) +} + +// D1ListCloudDatabasesForDatasource lists databases for an already-stored D1 Cloud +// datasource using its server-side token. The list/get payloads redact the API +// token to "[REDACTED]", so the edit form cannot pass it back; this resolves the +// real token (inline or secret-ref backed) by id without ever exposing it to the +// client. +func (a *App) D1ListCloudDatabasesForDatasource(id, accountID string) ([]D1CloudDatabase, error) { + id = strings.TrimSpace(id) + accountID = strings.TrimSpace(accountID) + if id == "" { + return nil, errors.New("datasource id is required") + } + if accountID == "" { + return nil, errors.New("accountId is required") + } + item, ok := a.store.Get(id) + if !ok { + return nil, errors.New("datasource not found") + } + // Only resolve and use the token for an actual D1 datasource. Other integrations + // may also store an options.apiToken, and this helper must never forward an + // unrelated (possibly SecretRef-backed) token to the Cloudflare D1 endpoint. + if item.Type != datasource.TypeD1 { + return nil, errors.New("datasource is not a Cloudflare D1 datasource") + } + // The server-side token is scoped to the datasource's configured Cloudflare + // account, and this binding exists precisely to use it without exposing it to + // the renderer. The account must therefore come from the stored datasource — a + // renderer caller must not redirect the stored secret to an arbitrary account. + // The account ID is a plaintext identifier (never a secret ref — see + // SupportedSecretFieldPath), so enforce the match locally BEFORE any + // secret-provider read; a mismatched request then fails fast and cheaply rather + // than triggering a Vault read that only fails on availability afterward. + storedAccount := strings.TrimSpace(optionAnyString(item.Options, "accountId")) + if storedAccount == "" { + return nil, errors.New("datasource has no configured account") + } + if accountID != storedAccount { + return nil, errors.New("accountId does not match the datasource account") + } + ctx := context.Background() + resolved, err := a.manager.ResolveDatasource(ctx, item) + if err != nil { + return nil, err + } + token := optionAnyString(resolved.Options, "apiToken") + if token == "" { + return nil, errors.New("token is required") + } + return a.d1ListCloudDatabases(ctx, storedAccount, token) +} + +func (a *App) D1CreateCloudDatabase(accountID, token, name string) (D1CloudDatabase, error) { + accountID = strings.TrimSpace(accountID) + token = strings.TrimSpace(token) + name = strings.TrimSpace(name) + if accountID == "" { + return D1CloudDatabase{}, errors.New("accountId is required") + } + if token == "" { + return D1CloudDatabase{}, errors.New("token is required") + } + if name == "" { + return D1CloudDatabase{}, errors.New("database name is required") + } + + endpoint := fmt.Sprintf( + "%s/accounts/%s/d1/database", + d1CloudflareBaseURL(), + url.PathEscape(accountID), + ) + body, err := json.Marshal(map[string]any{ + "name": name, + }) + if err != nil { + return D1CloudDatabase{}, err + } + raw, err := a.d1CloudflareRequest(context.Background(), http.MethodPost, endpoint, token, body) + if err != nil { + return D1CloudDatabase{}, err + } + + var envelope struct { + Success bool `json:"success"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + Result struct { + ID string `json:"id"` + UUID string `json:"uuid"` + DatabaseID string `json:"database_id"` + Name string `json:"name"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return D1CloudDatabase{}, err + } + if !envelope.Success { + if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" { + return D1CloudDatabase{}, errors.New(strings.TrimSpace(envelope.Errors[0].Message)) + } + return D1CloudDatabase{}, errors.New("create d1 database failed") + } + id := firstNonEmpty(envelope.Result.UUID, envelope.Result.ID, envelope.Result.DatabaseID) + if id == "" { + return D1CloudDatabase{}, errors.New("cloudflare create response missing database id") + } + return D1CloudDatabase{ + ID: id, + Name: strings.TrimSpace(envelope.Result.Name), + }, nil +} + +func (a *App) d1ListCloudDatabases(ctx context.Context, accountID, token string) ([]D1CloudDatabase, error) { + endpoint := fmt.Sprintf( + "%s/accounts/%s/d1/database", + d1CloudflareBaseURL(), + url.PathEscape(accountID), + ) + raw, err := a.d1CloudflareRequest(ctx, http.MethodGet, endpoint, token, nil) + if err != nil { + return nil, err + } + var envelope struct { + Success bool `json:"success"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + Result []struct { + ID string `json:"id"` + UUID string `json:"uuid"` + DatabaseID string `json:"database_id"` + Name string `json:"name"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, err + } + if !envelope.Success { + if len(envelope.Errors) > 0 && strings.TrimSpace(envelope.Errors[0].Message) != "" { + return nil, errors.New(strings.TrimSpace(envelope.Errors[0].Message)) + } + return nil, errors.New("list d1 databases failed") + } + out := make([]D1CloudDatabase, 0, len(envelope.Result)) + for _, item := range envelope.Result { + id := firstNonEmpty(item.UUID, item.ID, item.DatabaseID) + if id == "" { + continue + } + out = append(out, D1CloudDatabase{ + ID: id, + Name: strings.TrimSpace(item.Name), + }) + } + sort.Slice(out, func(i, j int) bool { + left := strings.ToLower(strings.TrimSpace(out[i].Name)) + right := strings.ToLower(strings.TrimSpace(out[j].Name)) + if left == right { + return out[i].ID < out[j].ID + } + return left < right + }) + return out, nil +} + +func d1CloudflareBaseURL() string { + return "https://api.cloudflare.com/client/v4" +} + +func (a *App) d1CloudflareRequest(ctx context.Context, method, endpoint, token string, body []byte) ([]byte, error) { + var reader io.Reader + if len(body) > 0 { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + + client := a.httpClient + if client == nil { + client = &http.Client{Timeout: 20 * time.Second} + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + snippet := strings.TrimSpace(string(raw)) + if len(snippet) > 500 { + snippet = snippet[:500] + "..." + } + if snippet != "" { + return nil, fmt.Errorf("d1 api request failed: %s: %s", resp.Status, snippet) + } + return nil, fmt.Errorf("d1 api request failed: %s", resp.Status) + } + return raw, nil +} + +func (a *App) d1ResolveWranglerToken(ctx context.Context, baseCommand []string) (string, error) { + command := append([]string{}, baseCommand...) + command = append(command, "auth", "token", "--json") + raw, err := a.runAppCommand(ctx, command) + if err != nil { + return "", fmt.Errorf("wrangler auth token failed: %w", err) + } + var payload struct { + Token string `json:"token"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return "", fmt.Errorf("parse wrangler auth token: %w", err) + } + token := strings.TrimSpace(payload.Token) + if token == "" { + return "", errors.New("wrangler auth token is empty") + } + return token, nil +} + +func (a *App) d1ResolveWranglerAccounts(ctx context.Context, baseCommand []string) ([]D1OAuthAccount, string, error) { + command := append([]string{}, baseCommand...) + command = append(command, "whoami", "--json") + raw, err := a.runAppCommand(ctx, command) + if err != nil { + return nil, "", fmt.Errorf("wrangler whoami failed: %w", err) + } + var payload struct { + AccountID string `json:"account_id"` + Accounts []struct { + ID string `json:"id"` + AccountTag string `json:"account_tag"` + Name string `json:"name"` + } `json:"accounts"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, "", fmt.Errorf("parse wrangler whoami: %w", err) + } + + selectedAccountID := strings.TrimSpace(payload.AccountID) + accounts := make([]D1OAuthAccount, 0, len(payload.Accounts)) + seen := map[string]struct{}{} + for _, account := range payload.Accounts { + accountID := strings.TrimSpace(firstNonEmpty(account.ID, account.AccountTag)) + if accountID == "" { + continue + } + if _, exists := seen[accountID]; exists { + continue + } + seen[accountID] = struct{}{} + accountName := strings.TrimSpace(account.Name) + if accountName == "" { + accountName = accountID + } + accounts = append(accounts, D1OAuthAccount{ + ID: accountID, + Name: accountName, + }) + } + + if selectedAccountID != "" { + if _, exists := seen[selectedAccountID]; !exists { + accounts = append(accounts, D1OAuthAccount{ + ID: selectedAccountID, + Name: selectedAccountID, + }) + seen[selectedAccountID] = struct{}{} + } + } + + sort.Slice(accounts, func(i, j int) bool { + leftName := strings.ToLower(strings.TrimSpace(accounts[i].Name)) + rightName := strings.ToLower(strings.TrimSpace(accounts[j].Name)) + if leftName == rightName { + return accounts[i].ID < accounts[j].ID + } + return leftName < rightName + }) + + if selectedAccountID == "" && len(accounts) == 1 { + selectedAccountID = accounts[0].ID + } + if len(accounts) == 0 && selectedAccountID == "" { + return nil, "", errors.New("wrangler account id not found") + } + return accounts, selectedAccountID, nil +} + +func (a *App) runAppCommand(ctx context.Context, command []string) ([]byte, error) { + if a != nil && a.runCommand != nil { + return a.runCommand(ctx, command) + } + return appRunCommand(ctx, command) +} + +func d1WranglerInstalled() bool { + return d1WranglerInstalledWithLookup(exec.LookPath) +} + +func d1WranglerInstalledWithLookup(lookup func(string) (string, error)) bool { + if lookup == nil { + return false + } + if _, err := lookup("wrangler"); err == nil { + return true + } + if _, err := lookup("npx"); err == nil { + return true + } + return false +} + +func appRunCommand(ctx context.Context, command []string) ([]byte, error) { + if len(command) == 0 { + return nil, errors.New("command is required") + } + cmd := exec.CommandContext(ctx, command[0], command[1:]...) + commandutil.ApplyStableWorkingDir(cmd) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + snippet := strings.TrimSpace(stderr.String()) + if snippet == "" { + snippet = strings.TrimSpace(stdout.String()) + } + if len(snippet) > 800 { + snippet = snippet[:800] + "..." + } + if snippet != "" { + return nil, fmt.Errorf("%w: %s", err, snippet) + } + return nil, err + } + return stdout.Bytes(), nil +} diff --git a/app_datasource_metrics.go b/app_datasource_metrics.go new file mode 100644 index 0000000..b5566c4 --- /dev/null +++ b/app_datasource_metrics.go @@ -0,0 +1,750 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "net" + "sort" + "strconv" + "strings" + "time" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" +) + +type DatasourceMetrics struct { + DatasourceID string `json:"datasourceId"` + DatasourceType datasource.DataSourceType `json:"datasourceType"` + CollectedAt int64 `json:"collectedAt"` + Node string `json:"node,omitempty"` + Nodes []string `json:"nodes,omitempty"` + + CPUAvailable bool `json:"cpuAvailable"` + CPUPercent float64 `json:"cpuPercent,omitempty"` + CPUUserSeconds float64 `json:"cpuUserSeconds,omitempty"` + CPUSystemSeconds float64 `json:"cpuSystemSeconds,omitempty"` + + MemoryAvailable bool `json:"memoryAvailable"` + MemoryUsedBytes int64 `json:"memoryUsedBytes,omitempty"` + MemoryTotalBytes int64 `json:"memoryTotalBytes,omitempty"` + MemoryUsedText string `json:"memoryUsedText,omitempty"` + MemoryTotalText string `json:"memoryTotalText,omitempty"` + + Warnings []string `json:"warnings,omitempty"` + Raw map[string]any `json:"raw,omitempty"` +} + +func (a *App) GetDatasourceMetrics(id string) (DatasourceMetrics, error) { + ds, ok := a.store.Get(id) + if !ok { + return DatasourceMetrics{}, errors.New("datasource not found") + } + return a.collectDatasourceMetrics(context.Background(), ds, ""), nil +} + +func (a *App) GetDatasourceMetricsByNode(id string, node string) (DatasourceMetrics, error) { + ds, ok := a.store.Get(id) + if !ok { + return DatasourceMetrics{}, errors.New("datasource not found") + } + return a.collectDatasourceMetrics(context.Background(), ds, node), nil +} + +func (a *App) collectDatasourceMetrics(ctx context.Context, ds datasource.DataSource, redisNode string) DatasourceMetrics { + metrics := DatasourceMetrics{ + DatasourceID: ds.ID, + DatasourceType: ds.Type, + CollectedAt: time.Now().UnixMilli(), + Warnings: make([]string, 0, 4), + Raw: map[string]any{}, + } + + appendWarning := func(message string) { + msg := strings.TrimSpace(message) + if msg == "" { + return + } + metrics.Warnings = append(metrics.Warnings, msg) + } + + switch ds.Type { + case datasource.TypeRedis: + a.collectRedisMetrics(ctx, ds, redisNode, &metrics, appendWarning) + case datasource.TypeElasticsearch: + a.collectElasticsearchMetrics(ctx, ds, &metrics, appendWarning) + case datasource.TypeMongoDB: + a.collectMongoMetrics(ctx, ds, &metrics, appendWarning) + case datasource.TypeMySQL: + a.collectMySQLMetrics(ctx, ds, &metrics, appendWarning) + case datasource.TypePostgreSQL: + a.collectPostgresMetrics(ctx, ds, &metrics, appendWarning) + default: + appendWarning(fmt.Sprintf("metrics collection is not implemented for datasource type %q", ds.Type)) + } + + if metrics.MemoryAvailable { + metrics.MemoryUsedText = formatMetricBytes(metrics.MemoryUsedBytes) + if metrics.MemoryTotalBytes > 0 { + metrics.MemoryTotalText = formatMetricBytes(metrics.MemoryTotalBytes) + } + } + + if len(metrics.Raw) == 0 { + metrics.Raw = nil + } + if len(metrics.Warnings) == 0 { + metrics.Warnings = nil + } + return metrics +} + +func (a *App) collectRedisMetrics(ctx context.Context, ds datasource.DataSource, requestedNode string, metrics *DatasourceMetrics, warn func(string)) { + requestedNode = normalizeRedisNodeAddress(requestedNode) + explicitNodeRequested := requestedNode != "" + + clusterNodes := a.loadRedisClusterNodes(ctx, ds) + if len(clusterNodes) == 0 { + clusterNodes = redisNodesFromDatasourceOptions(ds) + } + if len(clusterNodes) > 0 { + metrics.Nodes = clusterNodes + if explicitNodeRequested && !containsRedisNode(clusterNodes, requestedNode) { + metrics.Node = requestedNode + warn("redis node metrics unavailable: requested node not found in cluster topology") + return + } + selectedNode := selectRedisMetricsNode(requestedNode, clusterNodes) + if selectedNode != "" { + metrics.Node = selectedNode + metricsDS, err := datasourceWithRedisNode(ds, selectedNode) + if err == nil { + a.collectRedisMetricsCore(ctx, metricsDS, selectedNode, metrics, warn) + if explicitNodeRequested || metrics.MemoryAvailable || metrics.CPUAvailable { + return + } + warn("redis node metrics unavailable on selected node; retrying via datasource connection") + metrics.Node = "" + } else { + warn("redis node metrics unavailable: " + err.Error()) + if explicitNodeRequested { + return + } + metrics.Node = "" + } + } + } + + if explicitNodeRequested { + metrics.Node = requestedNode + metricsDS, err := datasourceWithRedisNode(ds, requestedNode) + if err != nil { + warn("redis node metrics unavailable: " + err.Error()) + return + } + warn("redis cluster topology unavailable: collecting metrics directly from requested node") + a.collectRedisMetricsCore(ctx, metricsDS, requestedNode, metrics, warn) + return + } + + a.collectRedisMetricsCore(ctx, ds, "", metrics, warn) +} + +func (a *App) collectRedisMetricsCore(ctx context.Context, ds datasource.DataSource, selectedNode string, metrics *DatasourceMetrics, warn func(string)) { + memResult, err := a.manager.ExecuteInternal(ctx, ds, "INFO memory", console.ExecuteOptions{}) + if err != nil { + warn("redis memory metrics unavailable: " + err.Error()) + } else if raw, ok := queryResultText(memResult); ok { + info := parseRedisInfo(raw) + if used, ok := parseInt64(info["used_memory"]); ok { + metrics.MemoryUsedBytes = used + metrics.MemoryAvailable = true + } + if total, ok := parseInt64(info["maxmemory"]); ok && total > 0 { + metrics.MemoryTotalBytes = total + } else if total, ok := parseInt64(info["total_system_memory"]); ok && total > 0 { + metrics.MemoryTotalBytes = total + } + if selectedNode != "" { + info["node"] = selectedNode + } + metrics.Raw["redis_memory"] = info + } else { + warn("redis memory metrics unavailable: INFO memory returned no payload") + } + + cpuResult, err := a.manager.ExecuteInternal(ctx, ds, "INFO cpu", console.ExecuteOptions{}) + if err != nil { + warn("redis cpu metrics unavailable: " + err.Error()) + return + } + raw, ok := queryResultText(cpuResult) + if !ok { + warn("redis cpu metrics unavailable: INFO cpu returned no payload") + return + } + info := parseRedisInfo(raw) + if user, ok := parseFloat64(info["used_cpu_user"]); ok { + metrics.CPUUserSeconds = user + metrics.CPUAvailable = true + } + if system, ok := parseFloat64(info["used_cpu_sys"]); ok { + metrics.CPUSystemSeconds = system + metrics.CPUAvailable = true + } + if selectedNode != "" { + info["node"] = selectedNode + } + metrics.Raw["redis_cpu"] = info +} + +func (a *App) collectElasticsearchMetrics(ctx context.Context, ds datasource.DataSource, metrics *DatasourceMetrics, warn func(string)) { + statement := "GET /_nodes/stats/process,jvm?filter_path=nodes.*.name,nodes.*.process.cpu.percent,nodes.*.jvm.mem.heap_used_in_bytes,nodes.*.jvm.mem.non_heap_used_in_bytes,nodes.*.jvm.mem.heap_max_in_bytes" + result, err := a.manager.ExecuteInternal(ctx, ds, statement, console.ExecuteOptions{}) + if err != nil { + warn("elasticsearch metrics unavailable: " + err.Error()) + return + } + row, ok := firstRow(result) + if !ok { + warn("elasticsearch metrics unavailable: empty response") + return + } + nodes, ok := anyMap(row["nodes"]) + if !ok || len(nodes) == 0 { + warn("elasticsearch metrics unavailable: missing nodes payload") + return + } + + var ( + cpuSum float64 + cpuCount float64 + memoryUsed int64 + memoryMax int64 + ) + for _, nodeAny := range nodes { + node, ok := anyMap(nodeAny) + if !ok { + continue + } + if cpu, ok := nestedNumber(node, "process", "cpu", "percent"); ok { + cpuSum += cpu + cpuCount++ + } + heapUsed, hasHeapUsed := nestedInt64(node, "jvm", "mem", "heap_used_in_bytes") + nonHeapUsed, hasNonHeapUsed := nestedInt64(node, "jvm", "mem", "non_heap_used_in_bytes") + if hasHeapUsed || hasNonHeapUsed { + memoryUsed += heapUsed + nonHeapUsed + } + if heapMax, ok := nestedInt64(node, "jvm", "mem", "heap_max_in_bytes"); ok { + memoryMax += heapMax + } + } + + if cpuCount > 0 { + metrics.CPUPercent = cpuSum / cpuCount + metrics.CPUAvailable = true + } + if memoryUsed > 0 { + metrics.MemoryUsedBytes = memoryUsed + metrics.MemoryAvailable = true + } + if memoryMax > 0 { + metrics.MemoryTotalBytes = memoryMax + } + metrics.Raw["elasticsearch_nodes"] = nodes +} + +func (a *App) collectMongoMetrics(ctx context.Context, ds datasource.DataSource, metrics *DatasourceMetrics, warn func(string)) { + result, err := a.manager.ExecuteInternal(ctx, ds, "db.serverStatus()", console.ExecuteOptions{}) + if err != nil { + warn("mongodb metrics unavailable: " + err.Error()) + return + } + row, ok := firstRow(result) + if !ok { + warn("mongodb metrics unavailable: empty response") + return + } + + if residentMB, ok := nestedNumber(row, "mem", "resident"); ok && residentMB > 0 { + metrics.MemoryUsedBytes = int64(math.Round(residentMB * 1024 * 1024)) + metrics.MemoryAvailable = true + } + if virtualMB, ok := nestedNumber(row, "mem", "virtual"); ok && virtualMB > 0 { + metrics.MemoryTotalBytes = int64(math.Round(virtualMB * 1024 * 1024)) + } + if userUS, ok := nestedNumber(row, "extra_info", "user_time_us"); ok && userUS >= 0 { + metrics.CPUUserSeconds = userUS / 1_000_000 + metrics.CPUAvailable = true + } + if systemUS, ok := nestedNumber(row, "extra_info", "system_time_us"); ok && systemUS >= 0 { + metrics.CPUSystemSeconds = systemUS / 1_000_000 + metrics.CPUAvailable = true + } + metrics.Raw["mongodb_server_status"] = row +} + +func (a *App) collectMySQLMetrics(ctx context.Context, ds datasource.DataSource, metrics *DatasourceMetrics, warn func(string)) { + result, err := a.manager.ExecuteInternal(ctx, ds, "SELECT COALESCE(SUM(CURRENT_NUMBER_OF_BYTES_USED),0) AS memory_used_bytes FROM performance_schema.memory_summary_global_by_event_name", console.ExecuteOptions{}) + if err == nil { + if row, ok := firstRow(result); ok { + if memoryUsed, ok := rowNumber(row, "memory_used_bytes"); ok { + metrics.MemoryUsedBytes = int64(memoryUsed) + metrics.MemoryAvailable = metrics.MemoryUsedBytes > 0 + } + } + } else { + warn("mysql used memory metrics unavailable: " + err.Error()) + } + + totalResult, err := a.manager.ExecuteInternal(ctx, ds, "SELECT @@innodb_buffer_pool_size AS memory_total_bytes", console.ExecuteOptions{}) + if err == nil { + if row, ok := firstRow(totalResult); ok { + if memoryTotal, ok := rowNumber(row, "memory_total_bytes", "@@innodb_buffer_pool_size"); ok { + metrics.MemoryTotalBytes = int64(memoryTotal) + if metrics.MemoryTotalBytes > 0 { + metrics.MemoryAvailable = true + } + } + } + } else { + warn("mysql total memory metrics unavailable: " + err.Error()) + } + + warn("mysql cpu percent is not available via standard SQL without extra instrumentation") +} + +func (a *App) collectPostgresMetrics(ctx context.Context, ds datasource.DataSource, metrics *DatasourceMetrics, warn func(string)) { + usedResult, err := a.manager.ExecuteInternal(ctx, ds, "SELECT COALESCE(SUM(total_bytes),0) AS memory_used_bytes FROM pg_backend_memory_contexts", console.ExecuteOptions{}) + if err == nil { + if row, ok := firstRow(usedResult); ok { + if used, ok := rowNumber(row, "memory_used_bytes"); ok { + metrics.MemoryUsedBytes = int64(used) + metrics.MemoryAvailable = metrics.MemoryUsedBytes > 0 + } + } + } else { + warn("postgres memory_used metrics unavailable: " + err.Error()) + } + + totalResult, err := a.manager.ExecuteInternal(ctx, ds, "SELECT pg_size_bytes(current_setting('shared_buffers')) AS memory_total_bytes", console.ExecuteOptions{}) + if err == nil { + if row, ok := firstRow(totalResult); ok { + if total, ok := rowNumber(row, "memory_total_bytes"); ok { + metrics.MemoryTotalBytes = int64(total) + if metrics.MemoryTotalBytes > 0 { + metrics.MemoryAvailable = true + } + } + } + } else { + warn("postgres memory_total metrics unavailable: " + err.Error()) + } + + cpuResult, err := a.manager.ExecuteInternal(ctx, ds, "SELECT COALESCE(SUM(user_time + system_time),0) AS cpu_seconds FROM pg_stat_kcache", console.ExecuteOptions{}) + if err == nil { + if row, ok := firstRow(cpuResult); ok { + if seconds, ok := rowNumber(row, "cpu_seconds"); ok { + metrics.CPUUserSeconds = seconds + metrics.CPUAvailable = seconds > 0 + } + } + } else { + warn("postgres cpu metrics require pg_stat_kcache extension: " + err.Error()) + } +} + +func (a *App) loadRedisClusterNodes(ctx context.Context, ds datasource.DataSource) []string { + result, err := a.manager.ExecuteInternal(ctx, ds, "CLUSTER NODES", console.ExecuteOptions{}) + if err != nil { + return nil + } + raw, ok := queryResultText(result) + if !ok { + return nil + } + return parseRedisClusterNodes(raw) +} + +func parseRedisClusterNodes(raw string) []string { + lines := strings.Split(strings.TrimSpace(raw), "\n") + nodes := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + flags := strings.Split(fields[2], ",") + if hasRedisClusterFlag(flags, "fail") || hasRedisClusterFlag(flags, "fail?") || hasRedisClusterFlag(flags, "handshake") || hasRedisClusterFlag(flags, "noaddr") { + continue + } + addr := normalizeRedisNodeAddress(fields[1]) + if addr != "" { + nodes = append(nodes, addr) + } + } + return uniqueRedisNodes(nodes) +} + +func hasRedisClusterFlag(flags []string, target string) bool { + for _, flag := range flags { + if strings.TrimSpace(flag) == target { + return true + } + } + return false +} + +func redisNodesFromDatasourceOptions(ds datasource.DataSource) []string { + if ds.Options == nil { + return nil + } + raw, ok := ds.Options["nodes"] + if !ok { + return nil + } + nodes := make([]string, 0, 4) + switch typed := raw.(type) { + case []any: + for _, node := range typed { + addr := normalizeRedisNodeAddress(fmt.Sprint(node)) + if addr != "" { + nodes = append(nodes, addr) + } + } + case []string: + for _, node := range typed { + addr := normalizeRedisNodeAddress(node) + if addr != "" { + nodes = append(nodes, addr) + } + } + case string: + parts := strings.FieldsFunc(typed, func(r rune) bool { + switch r { + case ',', ';', '\n', '\r', '\t', ' ': + return true + default: + return false + } + }) + for _, part := range parts { + addr := normalizeRedisNodeAddress(part) + if addr != "" { + nodes = append(nodes, addr) + } + } + } + return uniqueRedisNodes(nodes) +} + +func selectRedisMetricsNode(requestedNode string, nodes []string) string { + normalized := normalizeRedisNodeAddress(requestedNode) + if normalized != "" { + for _, node := range nodes { + if node == normalized { + return node + } + } + } + if len(nodes) == 0 { + return normalized + } + return nodes[0] +} + +func datasourceWithRedisNode(ds datasource.DataSource, node string) (datasource.DataSource, error) { + addr := normalizeRedisNodeAddress(node) + host, port, err := redisNodeHostPort(addr) + if err != nil { + return datasource.DataSource{}, err + } + next := ds + next.ID = fmt.Sprintf("%s|metrics|%s", ds.ID, addr) + next.Host = host + next.Port = port + next.Options = copyDatasourceOptions(ds.Options) + next.Options["__forceStandalone"] = true + delete(next.Options, "nodes") + return next, nil +} + +func copyDatasourceOptions(options map[string]any) map[string]any { + if len(options) == 0 { + return map[string]any{} + } + out := make(map[string]any, len(options)+1) + for key, value := range options { + out[key] = value + } + return out +} + +func redisNodeHostPort(node string) (string, int, error) { + node = strings.TrimSpace(node) + if node == "" { + return "", 0, errors.New("redis node address is required") + } + host, portText, err := net.SplitHostPort(node) + if err != nil { + idx := strings.LastIndex(node, ":") + if idx <= 0 || idx+1 >= len(node) { + return "", 0, fmt.Errorf("invalid redis node address %q", node) + } + host = strings.TrimSpace(node[:idx]) + portText = strings.TrimSpace(node[idx+1:]) + } + port, convErr := strconv.Atoi(portText) + if convErr != nil || port <= 0 || port > 65535 { + return "", 0, fmt.Errorf("invalid redis node address %q", node) + } + host = strings.TrimSpace(host) + if host == "" { + return "", 0, fmt.Errorf("invalid redis node address %q", node) + } + return host, port, nil +} + +func normalizeRedisNodeAddress(value string) string { + node := strings.TrimSpace(value) + if node == "" { + return "" + } + if idx := strings.Index(node, "@"); idx >= 0 { + node = strings.TrimSpace(node[:idx]) + } + return node +} + +func uniqueRedisNodes(nodes []string) []string { + if len(nodes) == 0 { + return nil + } + seen := make(map[string]struct{}, len(nodes)) + out := make([]string, 0, len(nodes)) + for _, node := range nodes { + normalized := normalizeRedisNodeAddress(node) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + sort.Strings(out) + return out +} + +func containsRedisNode(nodes []string, target string) bool { + target = normalizeRedisNodeAddress(target) + if target == "" { + return false + } + for _, node := range nodes { + if normalizeRedisNodeAddress(node) == target { + return true + } + } + return false +} + +func firstRow(result console.QueryResult) (map[string]any, bool) { + if len(result.Rows) == 0 || result.Rows[0] == nil { + return nil, false + } + return result.Rows[0], true +} + +func queryResultText(result console.QueryResult) (string, bool) { + row, ok := firstRow(result) + if !ok { + return "", false + } + if value, ok := row["result"]; ok { + switch typed := value.(type) { + case string: + return typed, true + case []byte: + return string(typed), true + default: + return fmt.Sprint(typed), true + } + } + return "", false +} + +func parseRedisInfo(raw string) map[string]string { + out := make(map[string]string) + lines := strings.Split(strings.TrimSpace(raw), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, ':') + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + if key != "" { + out[key] = val + } + } + return out +} + +func parseInt64(value string) (int64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + v, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return 0, false + } + return v, true +} + +func parseFloat64(value string) (float64, bool) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, false + } + v, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, false + } + return v, true +} + +func rowNumber(row map[string]any, keys ...string) (float64, bool) { + for _, key := range keys { + value, ok := row[key] + if !ok { + continue + } + if number, ok := anyNumber(value); ok { + return number, true + } + } + return 0, false +} + +func anyNumber(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int8: + return float64(typed), true + case int16: + return float64(typed), true + case int32: + return float64(typed), true + case int64: + return float64(typed), true + case uint: + return float64(typed), true + case uint8: + return float64(typed), true + case uint16: + return float64(typed), true + case uint32: + return float64(typed), true + case uint64: + return float64(typed), true + case json.Number: + if number, err := typed.Float64(); err == nil { + return number, true + } + case string: + if number, err := strconv.ParseFloat(strings.TrimSpace(typed), 64); err == nil { + return number, true + } + case []byte: + if number, err := strconv.ParseFloat(strings.TrimSpace(string(typed)), 64); err == nil { + return number, true + } + } + return 0, false +} + +func anyMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + default: + return nil, false + } +} + +func nestedNumber(root map[string]any, path ...string) (float64, bool) { + value, ok := nestedValue(root, path...) + if !ok { + return 0, false + } + return anyNumber(value) +} + +func nestedInt64(root map[string]any, path ...string) (int64, bool) { + number, ok := nestedNumber(root, path...) + if !ok { + return 0, false + } + return int64(number), true +} + +func nestedValue(root map[string]any, path ...string) (any, bool) { + var current any = root + for _, key := range path { + node, ok := anyMap(current) + if !ok { + return nil, false + } + next, ok := node[key] + if !ok { + return nil, false + } + current = next + } + return current, true +} + +func formatMetricBytes(value int64) string { + if value < 0 { + return "" + } + const unit = 1024.0 + if value < 1024 { + return fmt.Sprintf("%d B", value) + } + suffixes := []string{"KB", "MB", "GB", "TB", "PB"} + f := float64(value) + for _, suffix := range suffixes { + f = f / unit + if f < unit { + if f >= 100 { + return fmt.Sprintf("%.0f %s", f, suffix) + } + if f >= 10 { + return fmt.Sprintf("%.1f %s", f, suffix) + } + return fmt.Sprintf("%.2f %s", f, suffix) + } + } + return fmt.Sprintf("%.2f PB", f) +} diff --git a/app_datasource_metrics_test.go b/app_datasource_metrics_test.go new file mode 100644 index 0000000..e562868 --- /dev/null +++ b/app_datasource_metrics_test.go @@ -0,0 +1,718 @@ +package main + +import ( + "context" + "errors" + "math" + "path/filepath" + "strings" + "testing" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" +) + +type metricsStubAdapter struct { + execute func(ds datasource.DataSource, statement string) (console.QueryResult, error) +} + +func (m metricsStubAdapter) TestConnection(ctx context.Context, ds datasource.DataSource) error { + _ = ctx + _ = ds + return nil +} + +func (m metricsStubAdapter) ListEntities(ctx context.Context, ds datasource.DataSource, opts console.ListOptions) ([]string, error) { + _ = ctx + _ = ds + _ = opts + return nil, nil +} + +func (m metricsStubAdapter) DescribeEntity(ctx context.Context, ds datasource.DataSource, name string) (console.DescribeResult, error) { + _ = ctx + _ = ds + _ = name + return console.DescribeResult{}, nil +} + +func (m metricsStubAdapter) Execute(ctx context.Context, ds datasource.DataSource, statement string, opts console.ExecuteOptions) (console.QueryResult, error) { + _ = ctx + _ = opts + if m.execute == nil { + return console.QueryResult{}, errors.New("stub execute not configured") + } + return m.execute(ds, statement) +} + +func (m metricsStubAdapter) Explain(ctx context.Context, ds datasource.DataSource, statement string) (console.ExplainResult, error) { + _ = ctx + _ = ds + _ = statement + return console.ExplainResult{}, console.ErrUnsupported +} + +func TestGetDatasourceMetrics_UnknownDatasource(t *testing.T) { + app := &App{ + store: datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")), + } + _, err := app.GetDatasourceMetrics("missing") + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "not found") { + t.Fatalf("expected not found error, got %v", err) + } +} + +func TestGetDatasourceMetrics_Redis_ParsesMemoryAndCPU(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-metrics", + Type: datasource.TypeRedis, + Host: "127.0.0.1", + Port: 6379, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + _ = ds + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "INFO MEMORY": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# Memory\nused_memory:1048576\nmaxmemory:2097152\n", + }, + }, + }, nil + case "INFO CPU": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# CPU\nused_cpu_user:11.25\nused_cpu_sys:4.5\n", + }, + }, + }, nil + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetrics(created.ID) + if err != nil { + t.Fatalf("GetDatasourceMetrics: %v", err) + } + + if !metrics.MemoryAvailable { + t.Fatalf("expected memory to be available: %#v", metrics) + } + if !metrics.CPUAvailable { + t.Fatalf("expected cpu to be available: %#v", metrics) + } + if metrics.MemoryUsedBytes != 1_048_576 { + t.Fatalf("expected memory used 1048576, got %d", metrics.MemoryUsedBytes) + } + if metrics.MemoryTotalBytes != 2_097_152 { + t.Fatalf("expected memory total 2097152, got %d", metrics.MemoryTotalBytes) + } + if math.Abs(metrics.CPUUserSeconds-11.25) > 0.0001 { + t.Fatalf("expected cpu user seconds 11.25, got %f", metrics.CPUUserSeconds) + } + if math.Abs(metrics.CPUSystemSeconds-4.5) > 0.0001 { + t.Fatalf("expected cpu system seconds 4.5, got %f", metrics.CPUSystemSeconds) + } +} + +func TestGetDatasourceMetricsByNode_RedisClusterTargetsRequestedNode(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-metrics", + Type: datasource.TypeRedis, + Host: "10.0.0.1", + Port: 7000, + Options: map[string]any{ + "nodes": []string{"10.0.0.1:7000", "10.0.0.2:7001"}, + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + var targetNodeHits int + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + case "INFO MEMORY": + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + targetNodeHits++ + if forceStandalone, ok := ds.Options["__forceStandalone"].(bool); !ok || !forceStandalone { + t.Fatalf("expected __forceStandalone=true for node metrics datasource, got %#v", ds.Options["__forceStandalone"]) + } + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# Memory\nused_memory:2097152\nmaxmemory:4194304\n", + }, + }, + }, nil + } + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# Memory\nused_memory:1048576\nmaxmemory:2097152\n", + }, + }, + }, nil + case "INFO CPU": + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# CPU\nused_cpu_user:21.0\nused_cpu_sys:6.5\n", + }, + }, + }, nil + } + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": "# CPU\nused_cpu_user:11.0\nused_cpu_sys:4.0\n", + }, + }, + }, nil + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetricsByNode(created.ID, "10.0.0.2:7001") + if err != nil { + t.Fatalf("GetDatasourceMetricsByNode: %v", err) + } + if targetNodeHits == 0 { + t.Fatalf("expected INFO queries to target requested node") + } + if metrics.Node != "10.0.0.2:7001" { + t.Fatalf("expected selected node 10.0.0.2:7001, got %q", metrics.Node) + } + if len(metrics.Nodes) != 2 || metrics.Nodes[0] != "10.0.0.1:7000" || metrics.Nodes[1] != "10.0.0.2:7001" { + t.Fatalf("unexpected cluster node list: %#v", metrics.Nodes) + } + if metrics.MemoryUsedBytes != 2_097_152 { + t.Fatalf("expected memory used 2097152, got %d", metrics.MemoryUsedBytes) + } + if metrics.MemoryTotalBytes != 4_194_304 { + t.Fatalf("expected memory total 4194304, got %d", metrics.MemoryTotalBytes) + } + if math.Abs(metrics.CPUUserSeconds-21.0) > 0.0001 { + t.Fatalf("expected cpu user seconds 21.0, got %f", metrics.CPUUserSeconds) + } + if math.Abs(metrics.CPUSystemSeconds-6.5) > 0.0001 { + t.Fatalf("expected cpu system seconds 6.5, got %f", metrics.CPUSystemSeconds) + } +} + +func TestGetDatasourceMetrics_RedisClusterFallbackKeepsNodeUnsetWhenCollectionIsNotHostPinned(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-fallback", + Type: datasource.TypeRedis, + Host: "10.0.0.2", + Port: 7001, + Options: map[string]any{ + "nodes": []string{"10.0.0.1:7000", "10.0.0.2:7001"}, + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + var autoSelectedNodeInfoCalls int + var fallbackNodeInfoCalls int + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + case "INFO MEMORY": + if ds.Host == "10.0.0.1" && ds.Port == 7000 { + autoSelectedNodeInfoCalls++ + return console.QueryResult{}, errors.New("dial tcp 10.0.0.1:7000: connect: connection refused") + } + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + fallbackNodeInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:3145728\nmaxmemory:6291456\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected datasource target for INFO MEMORY") + case "INFO CPU": + if ds.Host == "10.0.0.1" && ds.Port == 7000 { + autoSelectedNodeInfoCalls++ + return console.QueryResult{}, errors.New("ERR unknown command `INFO cpu`") + } + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + fallbackNodeInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# CPU\nused_cpu_user:31.5\nused_cpu_sys:10.0\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected datasource target for INFO CPU") + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetrics(created.ID) + if err != nil { + t.Fatalf("GetDatasourceMetrics: %v", err) + } + + if autoSelectedNodeInfoCalls == 0 { + t.Fatalf("expected to attempt metrics collection on auto-selected node first") + } + if fallbackNodeInfoCalls == 0 { + t.Fatalf("expected fallback metrics collection via datasource connection") + } + if !metrics.MemoryAvailable || !metrics.CPUAvailable { + t.Fatalf("expected fallback metrics to be available, got %#v", metrics) + } + if metrics.MemoryUsedBytes != 3_145_728 || metrics.MemoryTotalBytes != 6_291_456 { + t.Fatalf("unexpected memory values after fallback: used=%d total=%d", metrics.MemoryUsedBytes, metrics.MemoryTotalBytes) + } + if math.Abs(metrics.CPUUserSeconds-31.5) > 0.0001 || math.Abs(metrics.CPUSystemSeconds-10.0) > 0.0001 { + t.Fatalf("unexpected cpu values after fallback: user=%f system=%f", metrics.CPUUserSeconds, metrics.CPUSystemSeconds) + } + if metrics.Node != "" { + t.Fatalf("expected node to remain empty when fallback collection is not host-pinned, got %q", metrics.Node) + } + if len(metrics.Warnings) == 0 { + t.Fatalf("expected warnings about the failed auto-selected node") + } +} + +func TestGetDatasourceMetrics_RedisClusterFallbackKeepsNodeUnsetWithBracketedIPv6DatasourceHost(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-fallback-ipv6-bracketed-host", + Type: datasource.TypeRedis, + Host: "[2001:db8::2]", + Port: 7001, + Options: map[string]any{ + "nodes": []string{"[2001:db8::1]:7000", "[2001:db8::2]:7001"}, + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + var autoSelectedNodeInfoCalls int + var fallbackNodeInfoCalls int + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a [2001:db8::1]:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b [2001:db8::2]:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + case "INFO MEMORY": + if ds.Host == "2001:db8::1" && ds.Port == 7000 { + autoSelectedNodeInfoCalls++ + return console.QueryResult{}, errors.New("dial tcp [2001:db8::1]:7000: connect: connection refused") + } + if ds.Host == "[2001:db8::2]" && ds.Port == 7001 { + fallbackNodeInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:3145728\nmaxmemory:6291456\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected datasource target for INFO MEMORY") + case "INFO CPU": + if ds.Host == "2001:db8::1" && ds.Port == 7000 { + autoSelectedNodeInfoCalls++ + return console.QueryResult{}, errors.New("ERR unknown command `INFO cpu`") + } + if ds.Host == "[2001:db8::2]" && ds.Port == 7001 { + fallbackNodeInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# CPU\nused_cpu_user:31.5\nused_cpu_sys:10.0\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected datasource target for INFO CPU") + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetrics(created.ID) + if err != nil { + t.Fatalf("GetDatasourceMetrics: %v", err) + } + + if autoSelectedNodeInfoCalls == 0 { + t.Fatalf("expected to attempt metrics collection on auto-selected node first") + } + if fallbackNodeInfoCalls == 0 { + t.Fatalf("expected fallback metrics collection via datasource connection") + } + if !metrics.MemoryAvailable || !metrics.CPUAvailable { + t.Fatalf("expected fallback metrics to be available, got %#v", metrics) + } + if metrics.Node != "" { + t.Fatalf("expected node to remain empty when fallback collection is not host-pinned, got %q", metrics.Node) + } +} + +func TestGetDatasourceMetricsByNode_DoesNotFallbackWhenRequestedNodeUnavailable(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-no-fallback-on-explicit-node", + Type: datasource.TypeRedis, + Host: "10.0.0.2", + Port: 7001, + Options: map[string]any{ + "nodes": []string{"10.0.0.1:7000", "10.0.0.2:7001"}, + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + var fallbackNodeInfoCalls int + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + case "INFO MEMORY", "INFO CPU": + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + fallbackNodeInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:3145728\nmaxmemory:6291456\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("node unavailable") + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetricsByNode(created.ID, "10.0.0.1:7000") + if err != nil { + t.Fatalf("GetDatasourceMetricsByNode: %v", err) + } + + if fallbackNodeInfoCalls > 0 { + t.Fatalf("did not expect fallback collection when a specific node was requested") + } + if metrics.Node != "10.0.0.1:7000" { + t.Fatalf("expected selected node to remain explicit request, got %q", metrics.Node) + } + if metrics.MemoryAvailable || metrics.CPUAvailable { + t.Fatalf("expected unavailable metrics when requested node cannot serve INFO, got %#v", metrics) + } + if len(metrics.Warnings) == 0 { + t.Fatalf("expected warnings when requested node is unavailable") + } +} + +func TestGetDatasourceMetricsByNode_DoesNotFallbackWhenRequestedNodeMissingFromTopology(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-no-fallback-on-missing-requested-node", + Type: datasource.TypeRedis, + Host: "10.0.0.2", + Port: 7001, + Options: map[string]any{ + "nodes": []string{"10.0.0.1:7000", "10.0.0.2:7001"}, + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + case "INFO MEMORY": + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:3145728\nmaxmemory:6291456\n"}, + }, + }, nil + case "INFO CPU": + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# CPU\nused_cpu_user:31.5\nused_cpu_sys:10.0\n"}, + }, + }, nil + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + missingNode := "10.0.0.9:7009" + metrics, err := app.GetDatasourceMetricsByNode(created.ID, missingNode) + if err != nil { + t.Fatalf("GetDatasourceMetricsByNode: %v", err) + } + + if metrics.Node != missingNode { + t.Fatalf("expected selected node to stay at missing requested node, got %q", metrics.Node) + } + if metrics.MemoryAvailable || metrics.CPUAvailable { + t.Fatalf("expected unavailable metrics when requested node is missing from topology, got %#v", metrics) + } + if len(metrics.Warnings) == 0 { + t.Fatalf("expected warning when requested node is missing from topology") + } + if !strings.Contains(strings.ToLower(strings.Join(metrics.Warnings, " ")), "not found") { + t.Fatalf("expected warning to mention requested node not found, got %v", metrics.Warnings) + } +} + +func TestGetDatasourceMetricsByNode_DoesNotFallbackToDatasourceWhenTopologyUnavailable(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster-no-fallback-when-topology-unavailable", + Type: datasource.TypeRedis, + Host: "10.0.0.2", + Port: 7001, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + const requestedNode = "10.0.0.1:7000" + + var ( + defaultInfoCalls int + requestedInfoCalls int + ) + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{}, errors.New("ERR unknown command 'CLUSTER'") + case "INFO MEMORY": + if ds.Host == "10.0.0.1" && ds.Port == 7000 { + requestedInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:1048576\nmaxmemory:2097152\n"}, + }, + }, nil + } + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + defaultInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# Memory\nused_memory:3145728\nmaxmemory:6291456\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected host for INFO memory") + case "INFO CPU": + if ds.Host == "10.0.0.1" && ds.Port == 7000 { + requestedInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# CPU\nused_cpu_user:9.5\nused_cpu_sys:2.5\n"}, + }, + }, nil + } + if ds.Host == "10.0.0.2" && ds.Port == 7001 { + defaultInfoCalls++ + return console.QueryResult{ + Rows: []map[string]any{ + {"result": "# CPU\nused_cpu_user:31.5\nused_cpu_sys:10.0\n"}, + }, + }, nil + } + return console.QueryResult{}, errors.New("unexpected host for INFO cpu") + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetricsByNode(created.ID, requestedNode) + if err != nil { + t.Fatalf("GetDatasourceMetricsByNode: %v", err) + } + + if defaultInfoCalls > 0 { + t.Fatalf("did not expect fallback collection via datasource default connection when topology is unavailable") + } + if requestedInfoCalls == 0 { + t.Fatalf("expected direct collection attempts on requested node when topology is unavailable") + } + if metrics.Node != requestedNode { + t.Fatalf("expected metrics node to remain explicit request, got %q", metrics.Node) + } + if !metrics.MemoryAvailable || !metrics.CPUAvailable { + t.Fatalf("expected metrics to be collected from requested node, got %#v", metrics) + } + if metrics.MemoryUsedBytes != 1_048_576 || metrics.MemoryTotalBytes != 2_097_152 { + t.Fatalf("unexpected memory values from requested node: used=%d total=%d", metrics.MemoryUsedBytes, metrics.MemoryTotalBytes) + } +} + +func TestGetDatasourceMetrics_Elasticsearch_AggregatesNodeStats(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "es-metrics", + Type: datasource.TypeElasticsearch, + Host: "127.0.0.1", + Port: 9200, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(datasource.TypeElasticsearch, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + _ = ds + if !strings.Contains(strings.ToLower(statement), "_nodes/stats") { + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + return console.QueryResult{ + Rows: []map[string]any{ + { + "nodes": map[string]any{ + "node-a": map[string]any{ + "process": map[string]any{ + "cpu": map[string]any{"percent": 32.0}, + }, + "jvm": map[string]any{ + "mem": map[string]any{ + "heap_used_in_bytes": 100.0, + "non_heap_used_in_bytes": 20.0, + "heap_max_in_bytes": 200.0, + }, + }, + }, + "node-b": map[string]any{ + "process": map[string]any{ + "cpu": map[string]any{"percent": 48.0}, + }, + "jvm": map[string]any{ + "mem": map[string]any{ + "heap_used_in_bytes": 300.0, + "non_heap_used_in_bytes": 30.0, + "heap_max_in_bytes": 400.0, + }, + }, + }, + }, + }, + }, + }, nil + }, + }) + + app := &App{store: store, manager: manager} + metrics, err := app.GetDatasourceMetrics(created.ID) + if err != nil { + t.Fatalf("GetDatasourceMetrics: %v", err) + } + + if !metrics.CPUAvailable { + t.Fatalf("expected cpu available: %#v", metrics) + } + if !metrics.MemoryAvailable { + t.Fatalf("expected memory available: %#v", metrics) + } + if math.Abs(metrics.CPUPercent-40.0) > 0.0001 { + t.Fatalf("expected cpu percent 40.0, got %f", metrics.CPUPercent) + } + if metrics.MemoryUsedBytes != 450 { + t.Fatalf("expected memory used 450, got %d", metrics.MemoryUsedBytes) + } + if metrics.MemoryTotalBytes != 600 { + t.Fatalf("expected memory total 600, got %d", metrics.MemoryTotalBytes) + } +} diff --git a/app_datasource_test.go b/app_datasource_test.go new file mode 100644 index 0000000..5513656 --- /dev/null +++ b/app_datasource_test.go @@ -0,0 +1,2520 @@ +package main + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/secrets" +) + +// The edit form refreshes D1 Cloud databases through the id-based binding because +// the list/get payloads redact the API token; the binding must validate its inputs +// and reject unknown datasources before attempting any token-bearing request. +func TestD1ListCloudDatabasesForDatasource_GuardsInputs(t *testing.T) { + app := &App{} + if _, err := app.D1ListCloudDatabasesForDatasource("", "acc"); err == nil { + t.Fatal("expected error when datasource id is empty") + } + if _, err := app.D1ListCloudDatabasesForDatasource("ds_1", ""); err == nil { + t.Fatal("expected error when accountId is empty") + } + + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + withStore := &App{store: store} + if _, err := withStore.D1ListCloudDatabasesForDatasource("missing", "acc"); err == nil { + t.Fatal("expected error when datasource does not exist") + } + + // A non-D1 datasource may also carry an options.apiToken; the helper must never + // forward an unrelated token to the Cloudflare D1 endpoint. + created, err := store.Create(datasource.DataSource{ + Name: "pg-with-token", + Type: datasource.TypePostgreSQL, + Options: map[string]any{"apiToken": "unrelated-token"}, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + if _, err := withStore.D1ListCloudDatabasesForDatasource(created.ID, "acc"); err == nil { + t.Fatal("expected error when datasource is not a D1 datasource") + } +} + +// The server-side token is scoped to the datasource's configured Cloudflare +// account. The binding must derive the account from the stored datasource and +// reject a caller-supplied account that differs, so a renderer caller cannot reuse +// the stored secret against an arbitrary account. +func TestD1ListCloudDatabasesForDatasource_BindsToStoredAccount(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + app := &App{store: store} + + noAccount, err := store.Create(datasource.DataSource{ + Name: "d1-no-account", + Type: datasource.TypeD1, + Options: map[string]any{"apiToken": "tok"}, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + if _, err := app.D1ListCloudDatabasesForDatasource(noAccount.ID, "acc_real"); err == nil { + t.Fatal("expected error when the datasource has no configured account") + } + + created, err := store.Create(datasource.DataSource{ + Name: "d1-cloud", + Type: datasource.TypeD1, + Options: map[string]any{"accountId": "acc_real", "apiToken": "tok"}, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + if _, err := app.D1ListCloudDatabasesForDatasource(created.ID, "acc_other"); err == nil { + t.Fatal("expected error when caller-supplied accountId does not match the stored account") + } +} + +// captureConnAdapter records the datasource a TestConnection reached so a test can +// assert which host/credential the manager actually dialed. +type captureConnAdapter struct { + called bool + gotDS datasource.DataSource +} + +func (a *captureConnAdapter) TestConnection(_ context.Context, ds datasource.DataSource) error { + a.called = true + a.gotDS = ds + return nil +} +func (a *captureConnAdapter) ListEntities(context.Context, datasource.DataSource, console.ListOptions) ([]string, error) { + return nil, nil +} +func (a *captureConnAdapter) DescribeEntity(context.Context, datasource.DataSource, string) (console.DescribeResult, error) { + return console.DescribeResult{}, nil +} +func (a *captureConnAdapter) Execute(context.Context, datasource.DataSource, string, console.ExecuteOptions) (console.QueryResult, error) { + return console.QueryResult{}, nil +} +func (a *captureConnAdapter) Explain(context.Context, datasource.DataSource, string) (console.ExplainResult, error) { + return console.ExplainResult{}, nil +} + +// passwordResolver mimics datasourcesecrets.Manager: it fills the inline password +// from a SecretRef so the test can detect when a secret was resolved. +type passwordResolver struct { + plaintext string +} + +func (r passwordResolver) ResolveDatasource(_ context.Context, ds datasource.DataSource) (datasource.DataSource, error) { + if ref, ok := ds.SecretRefs["password"]; ok && !ref.Empty() { + ds.Password = r.plaintext + } + return ds, nil +} + +func newRedisTestApp(t *testing.T) (*App, *captureConnAdapter) { + t.Helper() + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + adapter := &captureConnAdapter{} + manager := console.NewManager() + manager.Register(datasource.TypeRedis, adapter) + manager.SetDatasourceSecretResolver(passwordResolver{plaintext: "resolved-secret"}) + return &App{store: store, manager: manager}, adapter +} + +// A renderer that listed a ref-backed datasource must not be able to drive secret +// resolution toward a host it supplies in a fresh (unsaved) test payload: with no +// stored owner to bind the ref to, the test is rejected before any connection. +func TestTestDatasourcePayload_RejectsRefWithoutStoredOwner(t *testing.T) { + app, adapter := newRedisTestApp(t) + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "attacker.evil", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "k", Field: "f"}, + }, + }, "") + if ok || err == nil { + t.Fatalf("expected rejection of ref-backed test without a stored owner, got ok=%v err=%v", ok, err) + } + if adapter.called { + t.Fatal("connection must not be attempted when a ref cannot be bound to a stored datasource") + } +} + +// Pointing a stored datasource's secret ref at a new host (the exfiltration shape, +// and also a setting Save would persist) must be rejected, not silently resolved +// against either target. The stored secret is never sent to the edited host. +func TestTestDatasourcePayload_RejectsEditedSecretBackedTarget(t *testing.T) { + app, adapter := newRedisTestApp(t) + stored, err := app.store.Create(datasource.DataSource{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "k", Field: "f"}, + }, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "attacker.evil", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "k", Field: "f"}, + }, + }, stored.ID) + if ok || err == nil { + t.Fatalf("expected an edited secret-backed target to be rejected, got ok=%v err=%v", ok, err) + } + if adapter.called { + t.Fatal("the stored secret must not be resolved toward an edited/unsaved target") + } +} + +// Changing the reference itself (key/version) on an existing datasource must also be +// rejected until saved: the new ref is not yet bound to a persisted target. +func TestTestDatasourcePayload_RejectsEditedSecretRef(t *testing.T) { + app, adapter := newRedisTestApp(t) + stored, err := app.store.Create(datasource.DataSource{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "k", Field: "f"}, + }, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "rotated-key", Field: "f"}, + }, + }, stored.ID) + if ok || err == nil { + t.Fatalf("expected an edited secret ref to be rejected, got ok=%v err=%v", ok, err) + } + if adapter.called { + t.Fatal("an unsaved, edited ref must not be resolved") + } +} + +// Clicking Test on an unchanged stored ref-backed datasource resolves the stored +// secret against the stored target — exactly what Save would persist. +func TestTestDatasourcePayload_UnchangedSecretBackedTestsStored(t *testing.T) { + app, adapter := newRedisTestApp(t) + ref := secrets.SecretRef{ProviderConfigID: "vault-dev", Key: "k", Field: "f"} + stored, err := app.store.Create(datasource.DataSource{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{"password": ref}, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + // The redacted edit form round-trips the same ref and target with no typed password. + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{"password": ref}, + }, stored.ID) + if !ok || err != nil { + t.Fatalf("expected unchanged ref-backed test to succeed, got ok=%v err=%v", ok, err) + } + if adapter.gotDS.Host != "stored.internal" { + t.Fatalf("secret must be sent only to the stored host, got %q", adapter.gotDS.Host) + } + if adapter.gotDS.Password != "resolved-secret" { + t.Fatalf("stored ref should resolve to its secret, got %q", adapter.gotDS.Password) + } +} + +// Switching a stored ref-backed datasource back to a manually typed password drops +// the ref from the restored payload, so Test Connection must validate the newly +// typed credential against the form target — not silently re-resolve the old stored +// secret (which would report success for a change that fails after saving). +func TestTestDatasourcePayload_SwitchToManualTestsTypedCredential(t *testing.T) { + app, adapter := newRedisTestApp(t) + stored, err := app.store.Create(datasource.DataSource{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "k", Field: "f"}, + }, + }) + if err != nil { + t.Fatalf("seed datasource: %v", err) + } + // The form switched to manual entry: a typed password and no real ref. + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "stored.internal", Port: 6379, Password: "typed-pw", + }, stored.ID) + if !ok || err != nil { + t.Fatalf("expected manual-credential test to succeed, got ok=%v err=%v", ok, err) + } + if adapter.gotDS.Password != "typed-pw" { + t.Fatalf("must test the newly typed password, not the stored secret, got %q", adapter.gotDS.Password) + } +} + +// A payload with no secret references keeps the existing direct-test behaviour: +// the caller-supplied target and inline credential are used as-is. +func TestTestDatasourcePayload_NoRefTestsPayloadTarget(t *testing.T) { + app, adapter := newRedisTestApp(t) + ok, err := app.TestDatasourcePayload(DataSourcePayload{ + Name: "redis", Type: datasource.TypeRedis, + Host: "user.host", Port: 6379, Password: "inline-pw", + }, "") + if !ok || err != nil { + t.Fatalf("expected inline test to succeed, got ok=%v err=%v", ok, err) + } + if adapter.gotDS.Host != "user.host" || adapter.gotDS.Password != "inline-pw" { + t.Fatalf("inline test must use the payload target/credential, got host=%q pw=%q", adapter.gotDS.Host, adapter.gotDS.Password) + } +} + +func TestValidateDataSourcePayload_AllowsSQLWithOptionURISecretRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "vault-uri", + Type: datasource.TypePostgreSQL, + SecretRefs: map[string]secrets.SecretRef{ + "options.uri": {ProviderConfigID: "vault-dev", Key: "datasources/x/options/uri", Field: "uri"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("Wails SQL payload with options.uri secret ref should validate, got %v", err) + } +} + +// A D1 cloud token delegated to a SecretRef leaves options.apiToken empty by design, +// so the Wails validator must accept the resolvable ref as satisfying token auth — +// otherwise the GUI save/test path rejects it even though the secret is configured. +func TestValidateDataSourcePayload_AllowsD1CloudTokenViaSecretRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-cloud-token-ref", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_123", + "authMode": "token", + }, + SecretRefs: map[string]secrets.SecretRef{ + "options.apiToken": {ProviderConfigID: "vault-prod", Key: "cloudflare/d1/api-token", Field: "token"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("Wails D1 token delegated to a secret ref should validate, got %v", err) + } +} + +func TestValidateDataSourcePayload_D1CloudTokenRequiresApiToken(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-cloud-token", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_123", + "authMode": "token", + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatal("Wails D1 cloud token auth without an inline token or ref should be rejected") + } +} + +// The edit form has no UI for the options.uri ref and the type watcher applies the +// Mongo default port (27017) with an empty host, so the URI-ref exemption must hold +// regardless of host/port; otherwise an unrelated update rejects with host required. +func TestValidateDataSourcePayload_AllowsMongoURISecretRefWithDefaultPort(t *testing.T) { + payload := DataSourcePayload{ + Name: "vault-uri", + Type: datasource.TypeMongoDB, + Port: 27017, + SecretRefs: map[string]secrets.SecretRef{ + "options.uri": {ProviderConfigID: "vault-dev", Key: "datasources/x/options/uri", Field: "uri"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("Wails Mongo payload with options.uri ref and default port should validate, got %v", err) + } +} + +func TestValidateDataSourcePayload_RejectsIncompleteURISecretRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "partial-uri", + Type: datasource.TypePostgreSQL, + SecretRefs: map[string]secrets.SecretRef{ + "options.uri": {ProviderConfigID: "vault-dev"}, // no key/field + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatal("Wails SQL payload with incomplete options.uri secret ref should be rejected") + } +} + +// A non-UI caller can supply an incomplete password ref while still providing +// host/port; that must be rejected rather than persisted as a record that only +// fails at connection time. +func TestValidateDataSourcePayload_RejectsIncompletePasswordRefWithHostPort(t *testing.T) { + payload := DataSourcePayload{ + Name: "partial-password", + Type: datasource.TypePostgreSQL, + Host: "localhost", + Port: 5432, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev"}, // no key/field + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatal("payload with incomplete password secret ref should be rejected even with host/port") + } +} + +// When a password ref is present, ClearInlineSecretsForRefs strips the inline +// options.uri on save, so a URI-only SQL payload combined with a password ref +// would persist with no uri and no host/port — reject it at validation. +func TestValidateDataSourcePayload_RejectsSQLURIOnlyWithPasswordRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "uri-shadowed", + Type: datasource.TypePostgreSQL, + Options: map[string]any{ + "uri": "postgres://user@db.example.com:5432/app", + }, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "datasources/x/password", Field: "password"}, + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatal("SQL URI-only payload with a password ref should be rejected (uri is stripped on save)") + } +} + +// A password ref alongside host/port survives the save (only the inline uri is +// stripped), so it must still validate. +func TestValidateDataSourcePayload_AllowsSQLHostPortWithPasswordRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "host-port-ref", + Type: datasource.TypePostgreSQL, + Host: "db.example.com", + Port: 5432, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "datasources/x/password", Field: "password"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("SQL host/port payload with a password ref should validate, got %v", err) + } +} + +// A delegated options.uri ref is never stripped, so it satisfies addressing even +// when a password ref is also present. +func TestValidateDataSourcePayload_AllowsSQLURIRefWithPasswordRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "uri-ref-and-password-ref", + Type: datasource.TypePostgreSQL, + SecretRefs: map[string]secrets.SecretRef{ + "options.uri": {ProviderConfigID: "vault-dev", Key: "datasources/x/options/uri", Field: "uri"}, + "password": {ProviderConfigID: "vault-dev", Key: "datasources/x/password", Field: "password"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("SQL options.uri ref + password ref should validate, got %v", err) + } +} + +// Mongo mirrors SQL: an inline options.uri shadowed by a password ref is stripped, +// so a uri-only Mongo payload with a password ref must be rejected, while an +// explicit hosts list (never stripped) still satisfies addressing. +func TestValidateDataSourcePayload_RejectsMongoURIOnlyWithPasswordRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "mongo-uri-shadowed", + Type: datasource.TypeMongoDB, + Options: map[string]any{ + "uri": "mongodb://user@host1:27017/app", + }, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "datasources/x/password", Field: "password"}, + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatal("Mongo URI-only payload with a password ref should be rejected (uri is stripped on save)") + } +} + +func TestValidateDataSourcePayload_AllowsMongoHostsWithPasswordRef(t *testing.T) { + payload := DataSourcePayload{ + Name: "mongo-hosts-ref", + Type: datasource.TypeMongoDB, + Options: map[string]any{ + "hosts": []string{"host1:27017"}, + }, + SecretRefs: map[string]secrets.SecretRef{ + "password": {ProviderConfigID: "vault-dev", Key: "datasources/x/password", Field: "password"}, + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("Mongo hosts payload with a password ref should validate, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsElasticsearch(t *testing.T) { + payload := DataSourcePayload{ + Name: "es", + Type: datasource.TypeElasticsearch, + Host: "localhost", + Port: 9200, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_ElasticsearchRequiresHostPort(t *testing.T) { + payload := DataSourcePayload{ + Name: "es", + Type: datasource.TypeElasticsearch, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatalf("expected error") + } +} + +func TestValidateDataSourcePayload_AllowsMySQLURIWithoutHostPort(t *testing.T) { + payload := DataSourcePayload{ + Name: "mysql-uri", + Type: datasource.TypeMySQL, + Options: map[string]any{ + "uri": "mysql://root:secret@127.0.0.1:3306/mysql", + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsPostgresURIWithoutHostPort(t *testing.T) { + payload := DataSourcePayload{ + Name: "pg-uri", + Type: datasource.TypePostgreSQL, + Options: map[string]any{ + "uri": "postgresql://postgres:secret@127.0.0.1:5432/postgres", + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsDynamoDBWithRegion(t *testing.T) { + payload := DataSourcePayload{ + Name: "ddb", + Type: datasource.DataSourceType("dynamodb"), + Options: map[string]any{"region": "us-east-1"}, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_DynamoDBRequiresRegion(t *testing.T) { + payload := DataSourcePayload{ + Name: "ddb", + Type: datasource.DataSourceType("dynamodb"), + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatalf("expected error") + } else if err.Error() != "region is required" { + t.Fatalf("expected region is required, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsD1Cloud(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-cloud", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_123", + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_D1CloudRequiresAccountID(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-cloud", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "mode": "cloud", + "databaseId": "db_123", + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatalf("expected error") + } else if err.Error() != "accountId is required for d1" { + t.Fatalf("expected accountId is required for d1, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsD1Local(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-local", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "mode": "local", + "binding": "DB", + "databaseId": "local-db-id", + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_D1LocalRequiresBinding(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-local", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "mode": "local", + "databaseId": "local-db-id", + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatalf("expected error") + } else if err.Error() != "binding is required for local mode" { + t.Fatalf("expected binding is required for local mode, got %v", err) + } +} + +func TestValidateDataSourcePayload_AllowsD1OAuthFlowWithoutMode(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-oauth", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_123", + "databaseName": "analytics", + }, + } + if err := validateDataSourcePayload(payload); err != nil { + t.Fatalf("expected nil, got %v", err) + } +} + +func TestValidateDataSourcePayload_D1OAuthFlowRequiresAccountID(t *testing.T) { + payload := DataSourcePayload{ + Name: "d1-oauth", + Type: datasource.DataSourceType("d1"), + Options: map[string]any{ + "databaseId": "db_123", + "databaseName": "analytics", + }, + } + if err := validateDataSourcePayload(payload); err == nil { + t.Fatalf("expected error") + } else if err.Error() != "accountId is required for d1" { + t.Fatalf("expected accountId is required for d1, got %v", err) + } +} + +func TestD1ResolveWranglerAccounts_MultipleAccounts(t *testing.T) { + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + joined := strings.Join(command, " ") + if !strings.Contains(joined, "whoami --json") { + t.Fatalf("unexpected command: %q", joined) + } + return []byte(`{ + "account_id":"acc_beta", + "accounts":[ + {"id":"acc_beta","name":"Beta Team"}, + {"account_tag":"acc_alpha","name":"Alpha Team"} + ] + }`), nil + }, + } + + accounts, selected, err := app.d1ResolveWranglerAccounts(context.Background(), []string{"npx", "wrangler"}) + if err != nil { + t.Fatalf("d1ResolveWranglerAccounts: %v", err) + } + if selected != "acc_beta" { + t.Fatalf("expected selected account acc_beta, got %q", selected) + } + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(accounts)) + } + if accounts[0].ID != "acc_alpha" || accounts[0].Name != "Alpha Team" { + t.Fatalf("expected first account Alpha Team/acc_alpha, got %#v", accounts[0]) + } + if accounts[1].ID != "acc_beta" || accounts[1].Name != "Beta Team" { + t.Fatalf("expected second account Beta Team/acc_beta, got %#v", accounts[1]) + } +} + +func TestD1ResolveWranglerAccounts_SelectsSingleAccountWhenAccountIDMissing(t *testing.T) { + app := &App{ + runCommand: func(_ context.Context, _ []string) ([]byte, error) { + return []byte(`{ + "account_id":"", + "accounts":[ + {"id":"acc_only","name":"Only Team"} + ] + }`), nil + }, + } + + accounts, selected, err := app.d1ResolveWranglerAccounts(context.Background(), []string{"npx", "wrangler"}) + if err != nil { + t.Fatalf("d1ResolveWranglerAccounts: %v", err) + } + if len(accounts) != 1 || accounts[0].ID != "acc_only" { + t.Fatalf("expected one account acc_only, got %#v", accounts) + } + if selected != "acc_only" { + t.Fatalf("expected selected account acc_only, got %q", selected) + } +} + +func TestD1OAuthLogin_SkipsBrowserLoginWhenWranglerSessionAlreadyValid(t *testing.T) { + commands := make([]string, 0, 4) + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + joined := strings.Join(command, " ") + commands = append(commands, joined) + switch { + case strings.Contains(joined, "auth token --json"): + return []byte(`{"token":"token_ready"}`), nil + case strings.Contains(joined, "whoami --json"): + return []byte(`{ + "account_id":"acc_ready", + "accounts":[{"id":"acc_ready","name":"Ready Team"}] + }`), nil + case strings.HasSuffix(joined, "wrangler login"): + return nil, errors.New("login should not be called when token already works") + default: + return nil, errors.New("unexpected command: " + joined) + } + }, + } + + session, err := app.D1OAuthLogin() + if err != nil { + t.Fatalf("D1OAuthLogin: %v", err) + } + if strings.TrimSpace(session.Token) != "token_ready" { + t.Fatalf("expected token token_ready, got %q", session.Token) + } + if strings.TrimSpace(session.AccountID) != "acc_ready" { + t.Fatalf("expected account acc_ready, got %q", session.AccountID) + } + if len(session.Accounts) != 1 || session.Accounts[0].ID != "acc_ready" { + t.Fatalf("expected one account acc_ready, got %#v", session.Accounts) + } + if strings.Contains(strings.Join(commands, "\n"), "wrangler login") { + t.Fatalf("expected no wrangler login command, got %v", commands) + } +} + +func TestD1OAuthLogin_FallsBackToBrowserLoginWhenTokenMissing(t *testing.T) { + tokenCalls := 0 + loginCalls := 0 + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + joined := strings.Join(command, " ") + switch { + case strings.Contains(joined, "auth token --json"): + tokenCalls++ + if tokenCalls == 1 { + return nil, errors.New("wrangler auth token is empty") + } + return []byte(`{"token":"token_after_login"}`), nil + case strings.Contains(joined, "whoami --json"): + return []byte(`{ + "account_id":"acc_after_login", + "accounts":[{"id":"acc_after_login","name":"After Login Team"}] + }`), nil + case strings.HasSuffix(joined, "wrangler login"): + loginCalls++ + return []byte{}, nil + default: + return nil, errors.New("unexpected command: " + joined) + } + }, + } + + session, err := app.D1OAuthLogin() + if err != nil { + t.Fatalf("D1OAuthLogin: %v", err) + } + if loginCalls != 1 { + t.Fatalf("expected wrangler login to be called once, got %d", loginCalls) + } + if tokenCalls < 2 { + t.Fatalf("expected auth token command to retry after login, got %d calls", tokenCalls) + } + if strings.TrimSpace(session.Token) != "token_after_login" { + t.Fatalf("expected token token_after_login, got %q", session.Token) + } + if strings.TrimSpace(session.AccountID) != "acc_after_login" { + t.Fatalf("expected account acc_after_login, got %q", session.AccountID) + } +} + +func TestD1OAuthReLogin_AlwaysRunsBrowserLoginEvenWhenWranglerSessionAlreadyValid(t *testing.T) { + loginCalls := 0 + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + joined := strings.Join(command, " ") + switch { + case strings.Contains(joined, "auth token --json"): + return []byte(`{"token":"token_ready"}`), nil + case strings.Contains(joined, "whoami --json"): + return []byte(`{ + "account_id":"acc_ready", + "accounts":[{"id":"acc_ready","name":"Ready Team"}] + }`), nil + case strings.HasSuffix(joined, "wrangler login"): + loginCalls++ + return []byte{}, nil + default: + return nil, errors.New("unexpected command: " + joined) + } + }, + } + + session, err := app.D1OAuthReLogin() + if err != nil { + t.Fatalf("D1OAuthReLogin: %v", err) + } + if loginCalls != 1 { + t.Fatalf("expected wrangler login to be called once, got %d", loginCalls) + } + if strings.TrimSpace(session.Token) != "token_ready" { + t.Fatalf("expected token token_ready, got %q", session.Token) + } + if strings.TrimSpace(session.AccountID) != "acc_ready" { + t.Fatalf("expected account acc_ready, got %q", session.AccountID) + } +} + +func TestD1WranglerInstalledWithLookup_ReturnsTrueWhenWranglerBinaryExists(t *testing.T) { + lookup := func(name string) (string, error) { + if name == "wrangler" { + return "/usr/local/bin/wrangler", nil + } + return "", exec.ErrNotFound + } + + if !d1WranglerInstalledWithLookup(lookup) { + t.Fatalf("expected wrangler install check to pass when wrangler binary exists") + } +} + +func TestD1WranglerInstalledWithLookup_ReturnsTrueWhenNpxExists(t *testing.T) { + lookup := func(name string) (string, error) { + if name == "npx" { + return "/usr/local/bin/npx", nil + } + return "", exec.ErrNotFound + } + + if !d1WranglerInstalledWithLookup(lookup) { + t.Fatalf("expected wrangler install check to pass when npx exists") + } +} + +func TestD1WranglerInstalledWithLookup_ReturnsFalseWhenWranglerAndNpxMissing(t *testing.T) { + lookup := func(_ string) (string, error) { + return "", exec.ErrNotFound + } + + if d1WranglerInstalledWithLookup(lookup) { + t.Fatalf("expected wrangler install check to fail when binaries are missing") + } +} + +func TestCreateDatasource_D1DoesNotPersistWranglerTomlByDefault(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "Cloud D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_123", + "databaseName": "my-log-db", + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + if got := strings.TrimSpace(optionAnyString(created.Options, "binding")); got != "my_log_db" { + t.Fatalf("expected binding my_log_db, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(created.Options, "wranglerConfigPath")); got != "" { + t.Fatalf("expected wranglerConfigPath to be empty by default, got %q", got) + } + if raw, ok := created.Options["supportDev"]; ok { + if enabled, _ := raw.(bool); enabled { + t.Fatalf("expected supportDev false by default, got %#v", raw) + } + } +} + +func TestCreateDatasource_FreePlanBlocksFourthDatasource(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := store.Create(datasource.DataSource{ + Name: "Seed", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + authStore: newAuthStoreWithPlan(t, "free"), + } + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "Blocked", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }) + if err == nil { + t.Fatalf("expected free plan to block the fourth datasource") + } + if got := err.Error(); got != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %q", got) + } +} + +func TestCreateDatasource_FreePlanBlockedD1DoesNotWriteWranglerToml(t *testing.T) { + root := t.TempDir() + dataPath := filepath.Join(root, "datasources.json") + projectDir := filepath.Join(root, "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := store.Create(datasource.DataSource{ + Name: fmt.Sprintf("Seed %d", i), + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306 + i, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + authStore: newAuthStoreWithPlan(t, "free"), + } + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "Blocked D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_123", + "databaseName": "blocked-db", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err == nil { + t.Fatal("expected free plan to block blocked D1 datasource") + } + if got := err.Error(); got != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %q", got) + } + if got := len(store.List()); got != 3 { + t.Fatalf("expected datasource count to stay at 3, got %d", got) + } + if _, statErr := os.Stat(filepath.Join(projectDir, "wrangler.toml")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected no wrangler.toml to be created, got err=%v", statErr) + } +} + +func TestCreateDatasource_FreePlanBlockedRedisDoesNotProbeClusterNodes(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + for i := 0; i < 3; i++ { + if _, err := store.Create(datasource.DataSource{ + Name: fmt.Sprintf("Seed %d", i), + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306 + i, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %d: %v", i, err) + } + } + + probeCalls := 0 + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + _ = ds + probeCalls++ + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + }, + }) + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + manager: manager, + authStore: newAuthStoreWithPlan(t, "free"), + } + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "Blocked Redis", + Type: datasource.TypeRedis, + Host: "127.0.0.1", + Port: 7000, + }) + if err == nil { + t.Fatal("expected free plan to block blocked Redis datasource") + } + if got := err.Error(); got != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %q", got) + } + if probeCalls != 0 { + t.Fatalf("expected no Redis cluster probe when plan blocks create, got %d calls", probeCalls) + } + if got := len(store.List()); got != 3 { + t.Fatalf("expected datasource count to stay at 3, got %d", got) + } +} + +func TestCreateDatasource_D1WithDevProjectPathCreatesWranglerToml(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "Cloud D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_123", + "databaseName": "my-log-db", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + configPath := strings.TrimSpace(optionAnyString(created.Options, "wranglerConfigPath")) + if configPath == "" { + t.Fatalf("expected wranglerConfigPath to be set when dev support is enabled") + } + expectedConfigPath := filepath.Join(projectDir, "wrangler.toml") + if filepath.Clean(configPath) != filepath.Clean(expectedConfigPath) { + t.Fatalf("expected wranglerConfigPath %q, got %q", expectedConfigPath, configPath) + } + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read wrangler config: %v", err) + } + content := string(raw) + if !strings.Contains(content, "[[d1_databases]]") { + t.Fatalf("expected d1_databases section, got %q", content) + } + if !strings.Contains(content, "binding = \"my_log_db\"") { + t.Fatalf("expected binding in wrangler.toml, got %q", content) + } + if !strings.Contains(content, "database_name = \"my-log-db\"") { + t.Fatalf("expected database_name in wrangler.toml, got %q", content) + } + if !strings.Contains(content, "database_id = \"db_123\"") { + t.Fatalf("expected database_id in wrangler.toml, got %q", content) + } + if !strings.Contains(content, "migrations_dir = \"migrations/my-log-db-db_123\"") { + t.Fatalf("expected migrations_dir in wrangler.toml, got %q", content) + } + if got := strings.TrimSpace(optionAnyString(created.Options, "migrationsDir")); got != "migrations/my-log-db-db_123" { + t.Fatalf("expected migrationsDir migrations/my-log-db-db_123, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(created.Options, "devProjectPath")); filepath.Clean(got) != filepath.Clean(projectDir) { + t.Fatalf("expected devProjectPath %q, got %q", projectDir, got) + } + rawSupportDev, ok := created.Options["supportDev"] + if !ok { + t.Fatalf("expected supportDev option to be persisted") + } + if enabled, _ := rawSupportDev.(bool); !enabled { + t.Fatalf("expected supportDev=true, got %#v", rawSupportDev) + } +} + +func TestCreateDatasource_D1LocalWithoutDatabaseNameUsesDatabaseID(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "legacy-local", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "local", + "binding": "legacy_local", + "databaseId": "local-db-id", + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + if created.Database != "local-db-id" { + t.Fatalf("expected database to default to databaseId, got %q", created.Database) + } + if got := strings.TrimSpace(optionAnyString(created.Options, "databaseName")); got != "local-db-id" { + t.Fatalf("expected databaseName local-db-id, got %q", got) + } + if configPath := strings.TrimSpace(optionAnyString(created.Options, "wranglerConfigPath")); configPath != "" { + t.Fatalf("expected no wranglerConfigPath for legacy local datasource without dev project path, got %q", configPath) + } +} + +func TestCreateDatasource_D1CloudWithoutDatabaseNameUsesDatabaseID(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "legacy-cloud", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "cloud-db-id", + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + if created.Database != "cloud-db-id" { + t.Fatalf("expected database to default to databaseId, got %q", created.Database) + } + if got := strings.TrimSpace(optionAnyString(created.Options, "databaseName")); got != "cloud-db-id" { + t.Fatalf("expected databaseName cloud-db-id, got %q", got) + } + if configPath := strings.TrimSpace(optionAnyString(created.Options, "wranglerConfigPath")); configPath != "" { + t.Fatalf("expected no wranglerConfigPath for cloud datasource without dev project path, got %q", configPath) + } +} + +func TestCreateDatasource_D1AppendsDatabaseWhenProjectWranglerExists(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + existing := strings.Join([]string{ + "[[d1_databases]]", + `binding = "EXISTING_DB"`, + `database_name = "existing-db"`, + `database_id = "db_existing"`, + `migrations_dir = "migrations/existing-db"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatalf("write existing wrangler config: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + one, err := app.CreateDatasource(DataSourcePayload{ + Name: "App Logs", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_logs", + "databaseName": "my-log-db", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("CreateDatasource(one): %v", err) + } + + if got := strings.TrimSpace(optionAnyString(one.Options, "wranglerConfigPath")); filepath.Clean(got) != filepath.Clean(configPath) { + t.Fatalf("expected wranglerConfigPath %q, got %q", configPath, got) + } + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read wrangler config: %v", err) + } + content := string(raw) + if strings.Count(content, "[[d1_databases]]") != 2 { + t.Fatalf("expected two d1_databases blocks after append, got content: %q", content) + } + if !strings.Contains(content, `database_id = "db_existing"`) { + t.Fatalf("expected existing database entry to be preserved, got %q", content) + } + if !strings.Contains(content, `database_id = "db_logs"`) { + t.Fatalf("expected new database entry appended, got %q", content) + } + if !strings.Contains(content, `migrations_dir = "migrations/my-log-db-db_logs"`) { + t.Fatalf("expected new database migrations_dir, got %q", content) + } +} + +func TestUpdateDatasource_D1ReplacesBindingEntryWhenDatabaseIDChanges(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + existing := strings.Join([]string{ + "[[d1_databases]]", + `binding = "my_log_db"`, + `database_name = "my-log-db"`, + `database_id = "db_old"`, + `migrations_dir = "migrations/my-log-db"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil { + t.Fatalf("write existing wrangler config: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "App Logs", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_old", + "databaseName": "my-log-db", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "App Logs", + Type: datasource.TypeD1, + Options: map[string]any{ + "accountId": "acc_123", + "databaseId": "db_new", + "databaseName": "my-log-db", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "databaseId")); got != "db_new" { + t.Fatalf("expected datasource to use updated databaseId db_new, got %q", got) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read wrangler config: %v", err) + } + content := string(raw) + if strings.Count(content, "[[d1_databases]]") != 1 { + t.Fatalf("expected wrangler.toml to keep one d1_databases block for same binding, got %q", content) + } + if strings.Contains(content, `database_id = "db_old"`) { + t.Fatalf("expected old database_id to be replaced, got %q", content) + } + if !strings.Contains(content, `database_id = "db_new"`) { + t.Fatalf("expected wrangler entry to update to db_new, got %q", content) + } +} + +func TestUpdateDatasource_D1MissingDatasourceDoesNotWriteWranglerToml(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + missingID := "missing-d1-id" + _, err := app.UpdateDatasource(missingID, DataSourcePayload{ + Name: "Missing D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_missing", + }, + }) + if err == nil { + t.Fatalf("expected error for missing datasource") + } + if err.Error() != "datasource not found" { + t.Fatalf("expected datasource not found, got %v", err) + } + + // Missing datasource should fail before any metadata mutation. +} + +func TestUpdateDatasource_D1PreservesLegacyDevMetadataWhenSupportDevOmitted(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + legacyConfig := strings.Join([]string{ + "[[d1_databases]]", + `binding = "legacy_db"`, + `database_name = "legacy-db"`, + `database_id = "db_legacy"`, + `migrations_dir = "migrations/legacy-db"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("write legacy wrangler config: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + created, err := store.Create(datasource.DataSource{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Database: "legacy-db", + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_legacy", + "databaseName": "legacy-db", + "binding": "legacy_db", + "supportDev": false, + "wranglerConfigPath": configPath, + "migrationsDir": "migrations/legacy-db", + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_legacy", + "databaseName": "legacy-db", + "binding": "legacy_db", + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "wranglerConfigPath")); filepath.Clean(got) != filepath.Clean(configPath) { + t.Fatalf("expected legacy wranglerConfigPath preserved, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "migrationsDir")); got != "migrations/legacy-db-db_legacy" { + t.Fatalf("expected legacy migrationsDir to be normalized, got %q", got) + } + if !d1DatasourceSupportsDev(updated.Options) { + t.Fatalf("expected legacy datasource to still support dev after update") + } +} + +func TestUpdateDatasource_D1LegacyExplicitDisableClearsDevMetadata(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + legacyConfig := strings.Join([]string{ + "[[d1_databases]]", + `binding = "legacy_db"`, + `database_name = "legacy-db"`, + `database_id = "db_legacy"`, + `migrations_dir = "migrations/legacy-db"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil { + t.Fatalf("write legacy wrangler config: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + created, err := store.Create(datasource.DataSource{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Database: "legacy-db", + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_legacy", + "databaseName": "legacy-db", + "binding": "legacy_db", + "supportDev": false, + "wranglerConfigPath": configPath, + "migrationsDir": "migrations/legacy-db", + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_legacy", + "databaseName": "legacy-db", + "binding": "legacy_db", + "supportDev": false, + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "wranglerConfigPath")); got != "" { + t.Fatalf("expected wranglerConfigPath to be cleared when supportDev is explicitly disabled, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "migrationsDir")); got != "" { + t.Fatalf("expected migrationsDir to be cleared when supportDev is explicitly disabled, got %q", got) + } + if d1DatasourceSupportsDev(updated.Options) { + t.Fatalf("expected datasource to be remote-only after explicit supportDev=false") + } +} + +func TestD1MigrationDirNameIncludesDatabaseID(t *testing.T) { + gotOne := d1MigrationDirName("my-log-db", "db_one") + gotTwo := d1MigrationDirName("my-log-db", "db_two") + + if gotOne == gotTwo { + t.Fatalf("expected migration dir names to differ by database ID, got %q", gotOne) + } + if !strings.Contains(gotOne, "db_one") { + t.Fatalf("expected migration dir name to include database ID, got %q", gotOne) + } + if !strings.Contains(gotTwo, "db_two") { + t.Fatalf("expected migration dir name to include database ID, got %q", gotTwo) + } +} + +func TestUpdateDatasource_D1RenameKeepsMigrationDir(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "Rename Source A", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_rename_stable", + "databaseName": "rename-stable-db", + "binding": "RENAME_STABLE", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + createdMigrationDir := strings.TrimSpace(optionAnyString(created.Options, "migrationsDir")) + if createdMigrationDir == "" { + t.Fatalf("expected migrationsDir to be set on create") + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "Rename Source B", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_rename_stable", + "databaseName": "rename-stable-db", + "binding": "RENAME_STABLE", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + updatedMigrationDir := strings.TrimSpace(optionAnyString(updated.Options, "migrationsDir")) + if updatedMigrationDir == "" { + t.Fatalf("expected migrationsDir to be set on update") + } + if updatedMigrationDir != createdMigrationDir { + t.Fatalf("expected migrationsDir to stay stable across datasource renames, got %q and %q", createdMigrationDir, updatedMigrationDir) + } +} + +func TestUpdateDatasource_D1LegacyDevUpdatesWranglerConfig(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + initial := strings.Join([]string{ + "[[d1_databases]]", + `binding = "OLD_DB"`, + `database_name = "legacy-db"`, + `database_id = "db_old"`, + `migrations_dir = "migrations/old-dir"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil { + t.Fatalf("write wrangler config: %v", err) + } + + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + created, err := store.Create(datasource.DataSource{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Database: "legacy-db", + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_old", + "databaseName": "legacy-db", + "binding": "OLD_DB", + "supportDev": false, + "wranglerConfigPath": configPath, + "migrationsDir": "migrations/old-dir", + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "Legacy D1", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_new", + "databaseName": "legacy-db", + "binding": "NEW_DB", + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "wranglerConfigPath")); filepath.Clean(got) != filepath.Clean(configPath) { + t.Fatalf("expected wranglerConfigPath to remain %q, got %q", configPath, got) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read wrangler config: %v", err) + } + content := string(raw) + if strings.Count(content, "[[d1_databases]]") != 1 { + t.Fatalf("expected one d1_databases block after update, got %q", content) + } + if strings.Contains(content, `database_id = "db_old"`) { + t.Fatalf("expected old database_id to be replaced, got %q", content) + } + if !strings.Contains(content, `database_id = "db_new"`) { + t.Fatalf("expected new database_id in wrangler config, got %q", content) + } + if !strings.Contains(content, `binding = "NEW_DB"`) { + t.Fatalf("expected binding to be updated in wrangler config, got %q", content) + } +} + +func TestUpdateDatasource_D1SupportDevReplacesEntryWhenDatabaseAndBindingChange(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + projectDir := filepath.Join(t.TempDir(), "worker-project") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + configPath := filepath.Join(projectDir, "wrangler.toml") + initial := strings.Join([]string{ + "[[d1_databases]]", + `binding = "OLD_DB"`, + `database_name = "legacy-db"`, + `database_id = "db_old"`, + `migrations_dir = "migrations/old-dir"`, + "", + }, "\n") + if err := os.WriteFile(configPath, []byte(initial), 0o644); err != nil { + t.Fatalf("write wrangler config: %v", err) + } + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "D1 Dev", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_old", + "databaseName": "legacy-db", + "binding": "OLD_DB", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "D1 Dev", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_new", + "databaseName": "legacy-db", + "binding": "NEW_DB", + "supportDev": true, + "devProjectPath": projectDir, + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "databaseId")); got != "db_new" { + t.Fatalf("expected datasource to use updated databaseId db_new, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "binding")); got != "NEW_DB" { + t.Fatalf("expected datasource to use updated binding NEW_DB, got %q", got) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read wrangler config: %v", err) + } + content := string(raw) + if strings.Count(content, "[[d1_databases]]") != 1 { + t.Fatalf("expected wrangler.toml to keep one d1_databases block after update, got %q", content) + } + if strings.Contains(content, `database_id = "db_old"`) { + t.Fatalf("expected old database_id to be replaced, got %q", content) + } + if strings.Contains(content, `binding = "OLD_DB"`) { + t.Fatalf("expected old binding to be replaced, got %q", content) + } + if !strings.Contains(content, `database_id = "db_new"`) { + t.Fatalf("expected new database_id in wrangler config, got %q", content) + } + if !strings.Contains(content, `binding = "NEW_DB"`) { + t.Fatalf("expected new binding in wrangler config, got %q", content) + } +} + +func TestUpdateDatasource_D1LegacyDevMissingWranglerPathDoesNotFail(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + staleProjectDir := filepath.Join(t.TempDir(), "missing-worker-project") + configPath := filepath.Join(staleProjectDir, "wrangler.toml") + store := datasource.NewStore(dataPath) + if err := store.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + created, err := store.Create(datasource.DataSource{ + Name: "Legacy D1 Missing Path", + Type: datasource.TypeD1, + Database: "legacy-db-missing", + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_missing_old", + "databaseName": "legacy-db-missing", + "binding": "LEGACY_MISSING", + "supportDev": false, + "wranglerConfigPath": configPath, + "migrationsDir": "migrations/legacy-missing", + }, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + if _, statErr := os.Stat(staleProjectDir); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected stale project dir to be absent before update, got %v", statErr) + } + + app := &App{ + cfg: Config{DataPath: dataPath}, + store: store, + } + + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "Legacy D1 Missing Path", + Type: datasource.TypeD1, + Options: map[string]any{ + "mode": "cloud", + "accountId": "acc_123", + "databaseId": "db_missing_new", + "databaseName": "legacy-db-missing", + "binding": "LEGACY_MISSING_NEW", + "supportDev": false, + }, + }) + if err != nil { + t.Fatalf("UpdateDatasource should not fail for missing legacy wrangler path, got %v", err) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "wranglerConfigPath")); got != "" { + t.Fatalf("expected wranglerConfigPath to be cleared when legacy path is missing, got %q", got) + } + if got := strings.TrimSpace(optionAnyString(updated.Options, "migrationsDir")); got != "" { + t.Fatalf("expected migrationsDir to be cleared when legacy path is missing, got %q", got) + } + if d1DatasourceSupportsDev(updated.Options) { + t.Fatalf("expected datasource to fallback to remote-only when legacy wrangler path is missing") + } +} + +func TestD1MigrationDirNameDoesNotCollapseSubstringIDs(t *testing.T) { + gotOne := d1MigrationDirName("analytics", "ana") + gotTwo := d1MigrationDirName("analytics", "lyt") + if gotOne == gotTwo { + t.Fatalf("expected different migration dirs for substring database IDs, got %q", gotOne) + } + if gotOne != "analytics-ana" { + t.Fatalf("expected analytics-ana, got %q", gotOne) + } + if gotTwo != "analytics-lyt" { + t.Fatalf("expected analytics-lyt, got %q", gotTwo) + } +} + +func TestD1WranglerUpsertDatabaseEntry_ReplacesExistingDatabaseIDEntry(t *testing.T) { + content := strings.Join([]string{ + "[[d1_databases]]", + `binding = "LOG_DB"`, + `database_name = "my-log-db"`, + `database_id = "db_same"`, + `migrations_dir = "migrations/old-dir"`, + "", + }, "\n") + next, changed := d1WranglerUpsertDatabaseEntry(content, d1WranglerDatabaseEntry{ + Binding: "LOG_DB_NEW", + DatabaseName: "my-log-db", + DatabaseID: "db_same", + MigrationsDir: "migrations/new-dir", + }) + if !changed { + t.Fatalf("expected upsert to update existing database_id block") + } + if strings.Count(next, "[[d1_databases]]") != 1 { + t.Fatalf("expected one d1_databases block after replace, got %q", next) + } + if !strings.Contains(next, `database_id = "db_same"`) { + t.Fatalf("expected database_id to remain db_same, got %q", next) + } + if !strings.Contains(next, `binding = "LOG_DB_NEW"`) { + t.Fatalf("expected binding to update with replacement entry, got %q", next) + } + if !strings.Contains(next, `migrations_dir = "migrations/new-dir"`) { + t.Fatalf("expected migrations_dir to update for existing database_id, got %q", next) + } +} + +func TestD1WranglerUpsertDatabaseEntry_ReplacesDatabaseIDWithoutSpacing(t *testing.T) { + content := strings.Join([]string{ + "[[d1_databases]]", + `binding = "LOG_DB"`, + `database_name = "my-log-db"`, + `database_id="db_same"`, + `migrations_dir = "migrations/old-dir"`, + "", + }, "\n") + next, changed := d1WranglerUpsertDatabaseEntry(content, d1WranglerDatabaseEntry{ + Binding: "LOG_DB", + DatabaseName: "my-log-db", + DatabaseID: "db_same", + MigrationsDir: "migrations/new-dir", + }) + if !changed { + t.Fatalf("expected upsert to replace compact database_id syntax") + } + if strings.Count(next, "[[d1_databases]]") != 1 { + t.Fatalf("expected one d1_databases block after replace, got %q", next) + } + if !strings.Contains(next, `migrations_dir = "migrations/new-dir"`) { + t.Fatalf("expected migrations_dir to be updated, got %q", next) + } + if strings.Contains(next, `database_id="db_same"`) { + t.Fatalf("expected canonical formatting after replace, got %q", next) + } +} + +func TestD1WranglerUpsertDatabaseEntry_PreservesNonD1Sections(t *testing.T) { + content := strings.Join([]string{ + "[[d1_databases]]", + `binding = "LOG_DB"`, + `database_name = "my-log-db"`, + `database_id = "db_same"`, + `migrations_dir = "migrations/old-dir"`, + "", + "[vars]", + `FOO = "bar"`, + "", + }, "\n") + next, changed := d1WranglerUpsertDatabaseEntry(content, d1WranglerDatabaseEntry{ + Binding: "LOG_DB", + DatabaseName: "my-log-db", + DatabaseID: "db_same", + MigrationsDir: "migrations/new-dir", + }) + if !changed { + t.Fatalf("expected upsert to replace database entry") + } + if !strings.Contains(next, "[vars]") { + t.Fatalf("expected non-d1 section to remain after replace, got %q", next) + } + if !strings.Contains(next, `FOO = "bar"`) { + t.Fatalf("expected vars section values to remain after replace, got %q", next) + } + if !strings.Contains(next, `migrations_dir = "migrations/new-dir"`) { + t.Fatalf("expected d1 section to be updated, got %q", next) + } +} + +func TestCreateDatasource_RedisAutoDiscoversClusterNodes(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + _ = ds + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "redis-cluster", + Type: datasource.TypeRedis, + Host: "10.0.0.1", + Port: 7000, + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + + nodes := redisNodesFromDatasourceOptions(created) + if len(nodes) != 2 || nodes[0] != "10.0.0.1:7000" || nodes[1] != "10.0.0.2:7001" { + t.Fatalf("expected discovered nodes to be persisted, got %#v", nodes) + } +} + +func TestUpdateDatasource_RedisAutoDiscoversClusterNodes(t *testing.T) { + store := datasource.NewStore(filepath.Join(t.TempDir(), "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "redis-cluster", + Type: datasource.TypeRedis, + Host: "10.0.0.1", + Port: 7000, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + + manager := console.NewManager() + manager.Register(datasource.TypeRedis, metricsStubAdapter{ + execute: func(ds datasource.DataSource, statement string) (console.QueryResult, error) { + _ = ds + switch strings.ToUpper(strings.TrimSpace(statement)) { + case "CLUSTER NODES": + return console.QueryResult{ + Rows: []map[string]any{ + { + "result": strings.Join([]string{ + "node-a 10.0.0.1:7000@17000 master - 0 1700000000000 1 connected 0-5460", + "node-b 10.0.0.2:7001@17001 master - 0 1700000000000 2 connected 5461-10922", + }, "\n"), + }, + }, + }, nil + default: + return console.QueryResult{}, errors.New("unexpected statement: " + statement) + } + }, + }) + + app := &App{store: store, manager: manager} + updated, err := app.UpdateDatasource(created.ID, DataSourcePayload{ + Name: "redis-cluster", + Type: datasource.TypeRedis, + Host: "10.0.0.1", + Port: 7000, + }) + if err != nil { + t.Fatalf("UpdateDatasource: %v", err) + } + + nodes := redisNodesFromDatasourceOptions(updated) + if len(nodes) != 2 || nodes[0] != "10.0.0.1:7000" || nodes[1] != "10.0.0.2:7001" { + t.Fatalf("expected discovered nodes to be persisted on update, got %#v", nodes) + } +} + +func TestDynamoDBSSOListProfiles_ParsesDefaultAndNamedProfiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeAWSTestConfig(t, home, strings.Join([]string{ + "[default]", + "region = us-east-1", + "sso_start_url = https://example.awsapps.com/start", + "", + "[profile analytics]", + "region = us-west-2", + "sso_start_url = https://example.awsapps.com/start", + "", + }, "\n")) + + app := &App{} + profiles, err := app.DynamoDBSSOListProfiles("") + if err != nil { + t.Fatalf("DynamoDBSSOListProfiles: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("expected 2 profiles, got %d", len(profiles)) + } + if profiles[0].Name != "default" || profiles[0].Region != "us-east-1" { + t.Fatalf("expected default profile first, got %#v", profiles[0]) + } + if profiles[0].StartURL != "https://example.awsapps.com/start" { + t.Fatalf("expected default start url from config, got %#v", profiles[0]) + } + if profiles[1].Name != "analytics" || profiles[1].Region != "us-west-2" { + t.Fatalf("expected analytics profile second, got %#v", profiles[1]) + } + if profiles[1].StartURL != "https://example.awsapps.com/start" { + t.Fatalf("expected analytics start url from config, got %#v", profiles[1]) + } +} + +func TestDynamoDBSSOListProfiles_ResolvesSSOSessionSection(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeAWSTestConfig(t, home, strings.Join([]string{ + "[profile analytics]", + "region = ap-southeast-1", + "sso_session = corp-session", + "sso_account_id = 111111111111", + "sso_role_name = Developer", + "", + "[sso-session corp-session]", + "sso_start_url = https://portal.example.awsapps.com/start", + "sso_region = us-east-1", + "", + }, "\n")) + + app := &App{} + profiles, err := app.DynamoDBSSOListProfiles("") + if err != nil { + t.Fatalf("DynamoDBSSOListProfiles: %v", err) + } + if len(profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(profiles)) + } + if profiles[0].Name != "analytics" { + t.Fatalf("expected analytics profile, got %#v", profiles[0]) + } + if profiles[0].StartURL != "https://portal.example.awsapps.com/start" { + t.Fatalf("expected start url from sso-session, got %#v", profiles[0]) + } + if profiles[0].SSORegion != "us-east-1" { + t.Fatalf("expected sso region from sso-session, got %#v", profiles[0]) + } +} + +func TestDynamoDBSSOLogin_UsesProfileAndResolvesCachedToken(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + writeAWSTestConfig(t, home, strings.Join([]string{ + "[profile analytics]", + "sso_start_url = https://example.awsapps.com/start", + "sso_region = us-east-1", + "", + }, "\n")) + writeAWSTestSSOCacheFile(t, home, "cache.json", `{ + "startUrl": "https://example.awsapps.com/start", + "accessToken": "access_token_ready", + "expiresAt": "2099-01-01T00:00:00Z" + }`) + + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + t.Fatalf("unexpected shell command: %v", command) + return nil, nil + }, + } + + session, err := app.DynamoDBSSOLogin("analytics") + if err != nil { + t.Fatalf("DynamoDBSSOLogin: %v", err) + } + if strings.TrimSpace(session.AccessToken) != "access_token_ready" { + t.Fatalf("expected access_token_ready, got %q", session.AccessToken) + } + if strings.TrimSpace(session.ExpiresAt) == "" { + t.Fatalf("expected non-empty expiresAt") + } +} + +func TestDynamoDBSSOListAccounts_ParsesAccountList(t *testing.T) { + originalFactory := newDynamoDBSSOClient + t.Cleanup(func() { newDynamoDBSSOClient = originalFactory }) + + requests := 0 + newDynamoDBSSOClient = func(region string, _ *http.Client) dynamoDBSSOClient { + if region != "us-east-1" { + t.Fatalf("expected region us-east-1, got %s", region) + } + return &testDynamoDBSSOClient{ + listAccountsFn: func(_ context.Context, input dynamoDBSSOListAccountsInput) (dynamoDBSSOListAccountsOutput, error) { + requests++ + if input.AccessToken != "token_123" { + t.Fatalf("expected access token token_123, got %q", input.AccessToken) + } + if requests == 1 { + return dynamoDBSSOListAccountsOutput{ + AccountList: []dynamoDBSSOAccountInfo{ + {AccountID: "111111111111", AccountName: "Prod", EmailAddress: "prod@example.com"}, + }, + NextToken: "next-page", + }, nil + } + if input.NextToken != "next-page" { + t.Fatalf("expected next token next-page, got %q", input.NextToken) + } + return dynamoDBSSOListAccountsOutput{ + AccountList: []dynamoDBSSOAccountInfo{ + {AccountID: "222222222222", AccountName: "Dev", EmailAddress: "dev@example.com"}, + }, + }, nil + }, + } + } + + app := &App{} + accounts, err := app.DynamoDBSSOListAccounts("token_123", "us-east-1") + if err != nil { + t.Fatalf("DynamoDBSSOListAccounts: %v", err) + } + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(accounts)) + } + if accounts[0].AccountID != "222222222222" || accounts[0].AccountName != "Dev" { + t.Fatalf("unexpected first account: %#v", accounts[0]) + } + if requests != 2 { + t.Fatalf("expected 2 paginated requests, got %d", requests) + } +} + +func TestDynamoDBSSOListAccountRoles_ParsesRoleList(t *testing.T) { + originalFactory := newDynamoDBSSOClient + t.Cleanup(func() { newDynamoDBSSOClient = originalFactory }) + + newDynamoDBSSOClient = func(region string, _ *http.Client) dynamoDBSSOClient { + if region != "us-east-1" { + t.Fatalf("expected region us-east-1, got %s", region) + } + return &testDynamoDBSSOClient{ + listAccountRolesFn: func(_ context.Context, input dynamoDBSSOListAccountRolesInput) (dynamoDBSSOListAccountRolesOutput, error) { + if input.AccountID != "111111111111" { + t.Fatalf("expected account id 111111111111, got %q", input.AccountID) + } + if input.AccessToken != "token_123" { + t.Fatalf("expected access token token_123, got %q", input.AccessToken) + } + return dynamoDBSSOListAccountRolesOutput{ + RoleList: []dynamoDBSSORoleInfo{ + {RoleName: "Admin", AccountID: "111111111111"}, + {RoleName: "ReadOnly", AccountID: "111111111111"}, + }, + }, nil + }, + } + } + + app := &App{} + roles, err := app.DynamoDBSSOListAccountRoles("111111111111", "token_123", "us-east-1") + if err != nil { + t.Fatalf("DynamoDBSSOListAccountRoles: %v", err) + } + if len(roles) != 2 { + t.Fatalf("expected 2 roles, got %d", len(roles)) + } + if roles[0].RoleName != "Admin" || roles[0].AccountID != "111111111111" { + t.Fatalf("unexpected first role: %#v", roles[0]) + } +} + +func TestDynamoDBSSOGetRoleCredentials_ParsesRoleCredentials(t *testing.T) { + originalFactory := newDynamoDBSSOClient + t.Cleanup(func() { newDynamoDBSSOClient = originalFactory }) + + newDynamoDBSSOClient = func(region string, _ *http.Client) dynamoDBSSOClient { + if region != "us-east-1" { + t.Fatalf("expected region us-east-1, got %s", region) + } + return &testDynamoDBSSOClient{ + getRoleCredentialsFn: func(_ context.Context, input dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) { + if input.AccountID != "111111111111" { + t.Fatalf("expected account id 111111111111, got %q", input.AccountID) + } + if input.RoleName != "Admin" { + t.Fatalf("expected role name Admin, got %q", input.RoleName) + } + if input.AccessToken != "token_123" { + t.Fatalf("expected access token token_123, got %q", input.AccessToken) + } + return dynamoDBSSOGetRoleCredentialsOutput{ + RoleCredentials: &dynamoDBSSORoleCredentialsOutput{ + AccessKeyID: "AKIA_TEST", + SecretAccessKey: "SECRET_TEST", + SessionToken: "SESSION_TEST", + Expiration: 1735689600000, + }, + }, nil + }, + } + } + + app := &App{} + credentials, err := app.DynamoDBSSOGetRoleCredentials("111111111111", "Admin", "token_123", "us-east-1") + if err != nil { + t.Fatalf("DynamoDBSSOGetRoleCredentials: %v", err) + } + if credentials.AccessKeyID != "AKIA_TEST" { + t.Fatalf("expected AccessKeyID AKIA_TEST, got %q", credentials.AccessKeyID) + } + if credentials.SecretAccessKey != "SECRET_TEST" { + t.Fatalf("expected SecretAccessKey SECRET_TEST, got %q", credentials.SecretAccessKey) + } + if credentials.SessionToken != "SESSION_TEST" { + t.Fatalf("expected SessionToken SESSION_TEST, got %q", credentials.SessionToken) + } + if credentials.Expiration != 1735689600000 { + t.Fatalf("expected expiration 1735689600000, got %d", credentials.Expiration) + } +} + +func TestDynamoDBSSOListProfiles_UsesCustomConfigPath(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "custom-aws-config") + if err := os.WriteFile(configPath, []byte(strings.Join([]string{ + "[profile zoom-sso-dev]", + "region = ap-southeast-1", + "sso_region = us-east-1", + "sso_start_url = https://example.awsapps.com/start", + "sso_account_id = 111111111111", + "sso_role_name = Developer", + "", + }, "\n")), 0o644); err != nil { + t.Fatalf("write custom aws config: %v", err) + } + + app := &App{} + profiles, err := app.DynamoDBSSOListProfiles(configPath) + if err != nil { + t.Fatalf("DynamoDBSSOListProfiles custom path: %v", err) + } + if len(profiles) != 1 { + t.Fatalf("expected 1 profile, got %d", len(profiles)) + } + if profiles[0].Name != "zoom-sso-dev" { + t.Fatalf("expected zoom-sso-dev profile, got %#v", profiles[0]) + } + if profiles[0].Region != "ap-southeast-1" || profiles[0].SSORegion != "us-east-1" { + t.Fatalf("unexpected regions: %#v", profiles[0]) + } + if profiles[0].StartURL != "https://example.awsapps.com/start" { + t.Fatalf("expected start url from custom config, got %#v", profiles[0]) + } + if profiles[0].AccountID != "111111111111" || profiles[0].RoleName != "Developer" { + t.Fatalf("expected account/role from config, got %#v", profiles[0]) + } +} + +func TestDynamoDBSSOOAuthAuthorize_UsesAPIWithoutAWSCLI(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + configPath := filepath.Join(t.TempDir(), "custom-aws-config") + if err := os.WriteFile(configPath, []byte(strings.Join([]string{ + "[profile zoom-sso-dev]", + "region = ap-southeast-1", + "sso_region = us-east-1", + "sso_start_url = https://example.awsapps.com/start", + "sso_account_id = 111111111111", + "sso_role_name = Developer", + "", + }, "\n")), 0o644); err != nil { + t.Fatalf("write custom aws config: %v", err) + } + originalOIDCFactory := newDynamoDBSSOOIDCClient + originalSSOFactory := newDynamoDBSSOClient + originalOpenURL := openDynamoDBSSOVerificationURL + originalWait := waitDynamoDBSSOPollInterval + t.Cleanup(func() { + newDynamoDBSSOOIDCClient = originalOIDCFactory + newDynamoDBSSOClient = originalSSOFactory + openDynamoDBSSOVerificationURL = originalOpenURL + waitDynamoDBSSOPollInterval = originalWait + }) + + openCalls := 0 + openDynamoDBSSOVerificationURL = func(rawURL string) error { + openCalls++ + if rawURL != "https://device.example/verify-complete" { + t.Fatalf("unexpected verification URL: %s", rawURL) + } + return nil + } + waitCalls := 0 + waitDynamoDBSSOPollInterval = func(_ context.Context, _ time.Duration) error { + waitCalls++ + return nil + } + + tokenCalls := 0 + newDynamoDBSSOOIDCClient = func(region string, _ *http.Client) dynamoDBSSOOIDCClient { + if region != "us-east-1" { + t.Fatalf("expected OIDC region us-east-1, got %s", region) + } + return &testDynamoDBSSOOIDCClient{ + registerClientFn: func(_ context.Context, input dynamoDBSSOOIDCRegisterClientInput) (dynamoDBSSOOIDCRegisterClientOutput, error) { + if input.ClientName != "zoom-sso-dev" { + t.Fatalf("expected register-client name to use selected profile, got %q", input.ClientName) + } + if input.ClientType != "public" { + t.Fatalf("expected public client type, got %q", input.ClientType) + } + return dynamoDBSSOOIDCRegisterClientOutput{ + ClientID: "client-id", + ClientSecret: "client-secret", + }, nil + }, + startDeviceAuthorizationFn: func(_ context.Context, input dynamoDBSSOOIDCStartDeviceAuthorizationInput) (dynamoDBSSOOIDCStartDeviceAuthorizationOutput, error) { + if input.StartURL != "https://example.awsapps.com/start" { + t.Fatalf("unexpected start URL: %s", input.StartURL) + } + return dynamoDBSSOOIDCStartDeviceAuthorizationOutput{ + DeviceCode: "device-code", + VerificationURIComplete: "https://device.example/verify-complete", + ExpiresIn: 600, + Interval: 1, + }, nil + }, + createTokenFn: func(_ context.Context, input dynamoDBSSOOIDCCreateTokenInput) (dynamoDBSSOOIDCCreateTokenOutput, error) { + tokenCalls++ + if input.DeviceCode != "device-code" { + t.Fatalf("unexpected device code: %s", input.DeviceCode) + } + if tokenCalls == 1 { + return dynamoDBSSOOIDCCreateTokenOutput{}, &dynamoDBSSOAPIError{Code: "AuthorizationPendingException"} + } + return dynamoDBSSOOIDCCreateTokenOutput{ + AccessToken: "sdk_access_token", + ExpiresIn: 3600, + }, nil + }, + } + } + + newDynamoDBSSOClient = func(region string, _ *http.Client) dynamoDBSSOClient { + if region != "us-east-1" { + t.Fatalf("expected SSO region us-east-1, got %s", region) + } + return &testDynamoDBSSOClient{ + getRoleCredentialsFn: func(_ context.Context, input dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) { + if input.AccessToken != "sdk_access_token" { + t.Fatalf("expected sdk access token, got %q", input.AccessToken) + } + if input.AccountID != "111111111111" || input.RoleName != "Developer" { + t.Fatalf("unexpected account/role in input: %#v", input) + } + return dynamoDBSSOGetRoleCredentialsOutput{ + RoleCredentials: &dynamoDBSSORoleCredentialsOutput{ + AccessKeyID: "AKIA_TEST", + SecretAccessKey: "SECRET_TEST", + SessionToken: "SESSION_TEST", + Expiration: 1735689600000, + }, + }, nil + }, + } + } + + app := &App{ + runCommand: func(_ context.Context, command []string) ([]byte, error) { + t.Fatalf("unexpected shell command in SDK auth flow: %v", command) + return nil, nil + }, + } + + result, err := app.DynamoDBSSOOAuthAuthorize("zoom-sso-dev", "", configPath) + if err != nil { + t.Fatalf("DynamoDBSSOOAuthAuthorize: %v", err) + } + if result.Profile != "zoom-sso-dev" || result.Region != "ap-southeast-1" { + t.Fatalf("unexpected profile/region: %#v", result) + } + if result.AccountID != "111111111111" || result.RoleName != "Developer" { + t.Fatalf("unexpected account/role: %#v", result) + } + if result.AccessKeyID != "AKIA_TEST" || result.SecretAccessKey != "SECRET_TEST" || result.SessionToken != "SESSION_TEST" { + t.Fatalf("unexpected credentials: %#v", result) + } + if openCalls != 1 { + t.Fatalf("expected open URL to be called once, got %d", openCalls) + } + if tokenCalls != 2 { + t.Fatalf("expected create token to be called twice, got %d", tokenCalls) + } + if waitCalls != 1 { + t.Fatalf("expected one poll wait, got %d", waitCalls) + } +} + +type testDynamoDBSSOClient struct { + listAccountsFn func(ctx context.Context, params dynamoDBSSOListAccountsInput) (dynamoDBSSOListAccountsOutput, error) + listAccountRolesFn func(ctx context.Context, params dynamoDBSSOListAccountRolesInput) (dynamoDBSSOListAccountRolesOutput, error) + getRoleCredentialsFn func(ctx context.Context, params dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) +} + +func (c *testDynamoDBSSOClient) ListAccounts(ctx context.Context, params dynamoDBSSOListAccountsInput) (dynamoDBSSOListAccountsOutput, error) { + if c.listAccountsFn == nil { + return dynamoDBSSOListAccountsOutput{}, errors.New("unexpected ListAccounts call") + } + return c.listAccountsFn(ctx, params) +} + +func (c *testDynamoDBSSOClient) ListAccountRoles(ctx context.Context, params dynamoDBSSOListAccountRolesInput) (dynamoDBSSOListAccountRolesOutput, error) { + if c.listAccountRolesFn == nil { + return dynamoDBSSOListAccountRolesOutput{}, errors.New("unexpected ListAccountRoles call") + } + return c.listAccountRolesFn(ctx, params) +} + +func (c *testDynamoDBSSOClient) GetRoleCredentials(ctx context.Context, params dynamoDBSSOGetRoleCredentialsInput) (dynamoDBSSOGetRoleCredentialsOutput, error) { + if c.getRoleCredentialsFn == nil { + return dynamoDBSSOGetRoleCredentialsOutput{}, errors.New("unexpected GetRoleCredentials call") + } + return c.getRoleCredentialsFn(ctx, params) +} + +type testDynamoDBSSOOIDCClient struct { + registerClientFn func(ctx context.Context, params dynamoDBSSOOIDCRegisterClientInput) (dynamoDBSSOOIDCRegisterClientOutput, error) + startDeviceAuthorizationFn func(ctx context.Context, params dynamoDBSSOOIDCStartDeviceAuthorizationInput) (dynamoDBSSOOIDCStartDeviceAuthorizationOutput, error) + createTokenFn func(ctx context.Context, params dynamoDBSSOOIDCCreateTokenInput) (dynamoDBSSOOIDCCreateTokenOutput, error) +} + +func (c *testDynamoDBSSOOIDCClient) RegisterClient(ctx context.Context, params dynamoDBSSOOIDCRegisterClientInput) (dynamoDBSSOOIDCRegisterClientOutput, error) { + if c.registerClientFn == nil { + return dynamoDBSSOOIDCRegisterClientOutput{}, errors.New("unexpected RegisterClient call") + } + return c.registerClientFn(ctx, params) +} + +func (c *testDynamoDBSSOOIDCClient) StartDeviceAuthorization(ctx context.Context, params dynamoDBSSOOIDCStartDeviceAuthorizationInput) (dynamoDBSSOOIDCStartDeviceAuthorizationOutput, error) { + if c.startDeviceAuthorizationFn == nil { + return dynamoDBSSOOIDCStartDeviceAuthorizationOutput{}, errors.New("unexpected StartDeviceAuthorization call") + } + return c.startDeviceAuthorizationFn(ctx, params) +} + +func (c *testDynamoDBSSOOIDCClient) CreateToken(ctx context.Context, params dynamoDBSSOOIDCCreateTokenInput) (dynamoDBSSOOIDCCreateTokenOutput, error) { + if c.createTokenFn == nil { + return dynamoDBSSOOIDCCreateTokenOutput{}, errors.New("unexpected CreateToken call") + } + return c.createTokenFn(ctx, params) +} + +func writeAWSTestConfig(t *testing.T, homeDir, content string) { + t.Helper() + awsDir := filepath.Join(homeDir, ".aws") + if err := os.MkdirAll(awsDir, 0o755); err != nil { + t.Fatalf("mkdir aws dir: %v", err) + } + configPath := filepath.Join(awsDir, "config") + if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil { + t.Fatalf("write aws config: %v", err) + } +} + +func writeAWSTestSSOCacheFile(t *testing.T, homeDir, fileName, content string) { + t.Helper() + cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatalf("mkdir aws sso cache dir: %v", err) + } + cachePath := filepath.Join(cacheDir, fileName) + if err := os.WriteFile(cachePath, []byte(content), 0o644); err != nil { + t.Fatalf("write aws sso cache file: %v", err) + } +} diff --git a/app_diagnostics.go b/app_diagnostics.go new file mode 100644 index 0000000..399de44 --- /dev/null +++ b/app_diagnostics.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/diagnostics" +) + +func (a *App) GetDiagnosticsSettings() (diagnostics.Settings, error) { + if a == nil || a.diagnostics == nil { + return diagnostics.Settings{}, nil + } + return a.diagnostics.Current() +} + +func (a *App) SetDatasourceTimingLogEnabled(enabled bool) (diagnostics.Settings, error) { + if a == nil || a.diagnostics == nil { + return diagnostics.Settings{}, nil + } + settings, err := a.diagnostics.SetDatasourceTimingLogEnabled(enabled) + if err != nil { + return diagnostics.Settings{}, err + } + a.logInfof("source=settings event=datasource_timing_log_toggled enabled=%t", enabled) + return settings, nil +} + +type appDatasourceTimingStarter func(ctx context.Context, entrypoint string, ds datasource.DataSource, statement string, opts console.ExecuteOptions, approved bool) (context.Context, func(error)) + +func newAppDatasourceTimingStarter(store *diagnostics.Store, logger console.DatasourceTimingLogger) appDatasourceTimingStarter { + return func(ctx context.Context, entrypoint string, ds datasource.DataSource, statement string, opts console.ExecuteOptions, approved bool) (context.Context, func(error)) { + if ctx == nil { + ctx = context.Background() + } + if store == nil || logger == nil || !store.DatasourceTimingLogEnabled() { + return ctx, func(error) {} + } + trace := console.NewDatasourceTimingTrace(logger, console.NewDatasourceTimingMetadata(entrypoint, newDatasourceTimingRequestID(), ds, statement, opts, approved)) + ctx = console.WithDatasourceTimingTrace(ctx, trace) + console.DatasourceTimingEvent(ctx, "start") + return ctx, func(err error) { + trace.Finish(console.DatasourceTimingStatus(err), console.DatasourceTimingErrorFields(err)...) + } + } +} + +func (a *App) beginDatasourceTiming(ctx context.Context, entrypoint string, ds datasource.DataSource, statement string, opts console.ExecuteOptions, approved bool) (context.Context, func(error)) { + if ctx == nil { + ctx = context.Background() + } + if a == nil { + return ctx, func(error) {} + } + return newAppDatasourceTimingStarter(a.diagnostics, a.infoLog)(ctx, entrypoint, ds, statement, opts, approved) +} + +func newDatasourceTimingRequestID() string { + var buf [6]byte + if _, err := rand.Read(buf[:]); err == nil { + return hex.EncodeToString(buf[:]) + } + return fmt.Sprintf("%x", time.Now().UnixNano()) +} diff --git a/app_helpers.go b/app_helpers.go new file mode 100644 index 0000000..35a616e --- /dev/null +++ b/app_helpers.go @@ -0,0 +1,12 @@ +package main + +import "strings" + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/app_history.go b/app_history.go new file mode 100644 index 0000000..dfe4ba4 --- /dev/null +++ b/app_history.go @@ -0,0 +1,228 @@ +package main + +import ( + "errors" + "strings" + + "futrixdata/platform/internal/agentaudit" + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/history" +) + +type HistoryPayload struct { + DatasourceID string `json:"datasourceId"` + Statement string `json:"statement"` + Database string `json:"database"` +} + +type HistoryFilterPayload struct { + DatasourceID string `json:"datasourceId"` + Target string `json:"target"` + Database string `json:"database"` + Keyword string `json:"keyword"` + Limit int `json:"limit"` +} + +type AgentAuditFilterPayload struct { + AccessKey string `json:"accessKey"` + Protocol string `json:"protocol"` + Keyword string `json:"keyword"` + Limit int `json:"limit"` +} + +type AgentAuditEntryView struct { + ID string `json:"id"` + AccessKey string `json:"accessKey"` + AgentName string `json:"agentName"` + AgentType string `json:"agentType"` + Protocol string `json:"protocol"` + ToolName string `json:"toolName"` + Summary string `json:"summary"` + Statement string `json:"statement,omitempty"` + DatasourceID string `json:"datasourceId,omitempty"` + DatasourceName string `json:"datasourceName,omitempty"` + DatasourceType string `json:"datasourceType,omitempty"` + Target string `json:"target,omitempty"` + Status string `json:"status"` + Message string `json:"message,omitempty"` + RiskAttribution *agentaudit.RiskAttribution `json:"riskAttribution,omitempty"` + ExecutedAt string `json:"executedAt"` +} + +func (a *App) AppendHistory(payload HistoryPayload) (history.Entry, error) { + ds, ok := a.store.Get(payload.DatasourceID) + if !ok { + return history.Entry{}, errors.New("datasource not found") + } + stmt := strings.TrimSpace(payload.Statement) + if stmt == "" { + return history.Entry{}, nil + } + database := strings.TrimSpace(payload.Database) + if ds.Type == datasource.TypeElasticsearch { + database = "" + } else if database == "" { + database = ds.Database + } + if ds.Type == datasource.TypeRedis && database == "" { + database = "0" + } + var targets []string + switch ds.Type { + case datasource.TypeMySQL, datasource.TypePostgreSQL, datasource.TypeD1: + targets = history.ExtractSQLTargets(stmt) + case datasource.TypeMongoDB: + collection, _, err := console.ParseMongoTarget(stmt) + if err == nil && collection != "" { + targets = []string{collection} + } + case datasource.TypeElasticsearch: + indices, err := console.ParseElasticsearchTargets(stmt) + if err == nil && len(indices) > 0 { + targets = indices + } + } + return a.historyStore.Append(history.AppendInput{ + DatasourceID: ds.ID, + DatasourceName: ds.Name, + DatasourceType: string(ds.Type), + Database: database, + Statement: stmt, + Targets: targets, + }) +} + +func (a *App) ListHistory(filter HistoryFilterPayload) ([]history.Entry, error) { + return a.historyStore.List(history.Filter{ + DatasourceID: filter.DatasourceID, + Target: filter.Target, + Database: filter.Database, + Keyword: filter.Keyword, + Limit: filter.Limit, + }), nil +} + +func (a *App) GetHistory(id string) (history.Entry, error) { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return history.Entry{}, errors.New("history id is required") + } + entry, ok := a.historyStore.GetByID(trimmed) + if !ok { + return history.Entry{}, errors.New("history entry not found") + } + return entry, nil +} + +func (a *App) DeleteHistory(id string) (bool, error) { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return false, errors.New("history id is required") + } + return a.historyStore.DeleteByID(trimmed), nil +} + +func (a *App) ClearHistory(filter HistoryFilterPayload) (int, error) { + count := a.historyStore.Clear(history.Filter{ + DatasourceID: filter.DatasourceID, + Target: filter.Target, + Database: filter.Database, + Keyword: filter.Keyword, + }) + return count, nil +} + +func (a *App) ListAgentAudit(filter AgentAuditFilterPayload) ([]AgentAuditEntryView, error) { + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(a.cfg.DataPath)) + keyword := strings.TrimSpace(filter.Keyword) + listLimit := filter.Limit + if keyword != "" { + listLimit = 0 + } + items, err := auditStore.List(agentaudit.AuditFilter{ + AccessKey: strings.TrimSpace(filter.AccessKey), + Protocol: strings.TrimSpace(filter.Protocol), + Keyword: "", + Limit: listLimit, + }) + if err != nil { + return nil, err + } + identities, err := identityStore.ListAll() + if err != nil { + return nil, err + } + identityByAccessKey := make(map[string]agentaudit.AgentIdentity, len(identities)) + for _, identity := range identities { + identityByAccessKey[identity.AccessKey] = identity + } + out := make([]AgentAuditEntryView, 0, len(items)) + for _, item := range items { + view := AgentAuditEntryView{ + ID: item.ID, + AccessKey: item.AccessKey, + Protocol: item.Protocol, + ToolName: item.ToolName, + Summary: item.Summary, + Statement: item.Statement, + DatasourceID: item.DatasourceID, + DatasourceName: item.DatasourceName, + DatasourceType: item.DatasourceType, + Target: item.Target, + Status: item.Status, + Message: item.Message, + RiskAttribution: item.RiskAttribution, + ExecutedAt: item.ExecutedAt, + } + if identity, ok := identityByAccessKey[item.AccessKey]; ok { + view.AgentName = identity.Name + view.AgentType = identity.AgentType + } + if keyword != "" && !matchesAgentAuditKeyword(view, keyword) { + continue + } + out = append(out, view) + } + if filter.Limit > 0 && len(out) > filter.Limit { + out = out[:filter.Limit] + } + return out, nil +} + +func matchesAgentAuditKeyword(entry AgentAuditEntryView, keyword string) bool { + parts := []string{ + entry.AccessKey, + entry.AgentName, + entry.AgentType, + entry.Protocol, + entry.ToolName, + entry.Summary, + entry.Statement, + entry.DatasourceName, + entry.DatasourceType, + entry.Target, + entry.Status, + entry.Message, + } + // The audit card now surfaces the matched-rule attribution (rule id, code, + // description, action, level, source, and reasons). Keyword search has to + // cover the same fields; otherwise users searching for visible text like + // `delete_full_table`, the source bucket (`risk_engine` / `policy`), or a + // reason fragment silently get empty results. + if attr := entry.RiskAttribution; attr != nil { + parts = append(parts, + attr.RuleID, + attr.RuleCode, + attr.RuleDescription, + attr.Action, + attr.Level, + attr.Source, + ) + parts = append(parts, attr.Reasons...) + } + haystack := strings.ToLower(strings.Join(parts, " ")) + return strings.Contains(haystack, strings.ToLower(strings.TrimSpace(keyword))) +} diff --git a/app_history_test.go b/app_history_test.go new file mode 100644 index 0000000..3cba762 --- /dev/null +++ b/app_history_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "path/filepath" + "testing" + + "futrixdata/platform/internal/agentaudit" + "futrixdata/platform/internal/bootstrap" +) + +func TestAppListAgentAuditMatchesAgentIdentityKeyword(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + app := &App{cfg: Config{DataPath: dataPath}} + + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)) + identity, err := identityStore.CreateManual("warehouse-bot") + if err != nil { + t.Fatalf("CreateManual: %v", err) + } + + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(dataPath)) + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: identity.AccessKey, + Protocol: "skill", + ToolName: "execute_statement", + Summary: "SELECT * FROM users", + Status: agentaudit.StatusSuccess, + ExecutedAt: "2026-04-23T10:00:00Z", + }); err != nil { + t.Fatalf("Append: %v", err) + } + + items, err := app.ListAgentAudit(AgentAuditFilterPayload{Keyword: "warehouse", Limit: 50}) + if err != nil { + t.Fatalf("ListAgentAudit: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 filtered audit entry, got %#v", items) + } + if items[0].AgentName != "warehouse-bot" { + t.Fatalf("agent name = %q, want warehouse-bot", items[0].AgentName) + } +} + +func TestAppListAgentAuditSearchesBeyondInitialLimitWhenMatchingIdentity(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + app := &App{cfg: Config{DataPath: dataPath}} + + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)) + matchingIdentity, err := identityStore.CreateManual("warehouse-bot") + if err != nil { + t.Fatalf("CreateManual matching identity: %v", err) + } + otherIdentity, err := identityStore.CreateManual("query-bot") + if err != nil { + t.Fatalf("CreateManual other identity: %v", err) + } + + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(dataPath)) + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: matchingIdentity.AccessKey, + Protocol: "skill", + ToolName: "execute_statement", + Summary: "SELECT * FROM warehouse_inventory", + Status: agentaudit.StatusSuccess, + ExecutedAt: "2026-04-23T10:00:00Z", + }); err != nil { + t.Fatalf("Append matching entry: %v", err) + } + for i := 0; i < 6; i++ { + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: otherIdentity.AccessKey, + Protocol: "skill", + ToolName: "execute_statement", + Summary: "SELECT * FROM users", + Status: agentaudit.StatusSuccess, + ExecutedAt: "2026-04-23T10:00:00Z", + }); err != nil { + t.Fatalf("Append other entry %d: %v", i, err) + } + } + + items, err := app.ListAgentAudit(AgentAuditFilterPayload{Keyword: "warehouse-bot", Limit: 1}) + if err != nil { + t.Fatalf("ListAgentAudit: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 filtered audit entry, got %#v", items) + } + if items[0].AgentName != "warehouse-bot" { + t.Fatalf("agent name = %q, want warehouse-bot", items[0].AgentName) + } + if items[0].AccessKey != matchingIdentity.AccessKey { + t.Fatalf("access key = %q, want %q", items[0].AccessKey, matchingIdentity.AccessKey) + } +} + +func TestAppListAgentAuditKeywordMatchesRiskAttribution(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + app := &App{cfg: Config{DataPath: dataPath}} + + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)) + identity, err := identityStore.CreateManual("warehouse-bot") + if err != nil { + t.Fatalf("CreateManual: %v", err) + } + + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(dataPath)) + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: identity.AccessKey, + Protocol: "skill", + ToolName: "execute_statement", + Summary: "DELETE on users", + Status: agentaudit.StatusSuccess, + ExecutedAt: "2026-04-23T10:00:00Z", + RiskAttribution: &agentaudit.RiskAttribution{ + Source: agentaudit.AttributionSourceRiskEngine, + Action: "require_approval", + Level: "high", + RuleID: "rule_delete", + RuleCode: "delete_full_table", + RuleDescription: "DELETE without WHERE clause", + Reasons: []string{"DELETE statement on `users` does not include a WHERE clause"}, + }, + }); err != nil { + t.Fatalf("Append: %v", err) + } + + // Each visible RiskAttribution field should be reachable by keyword + // search; the audit card displays them, so excluding them from the + // haystack would silently filter the row out. + cases := []string{"delete_full_table", "DELETE without WHERE clause", "WHERE clause", "rule_delete", "high", "risk_engine"} + for _, kw := range cases { + t.Run(kw, func(t *testing.T) { + items, err := app.ListAgentAudit(AgentAuditFilterPayload{Keyword: kw, Limit: 50}) + if err != nil { + t.Fatalf("ListAgentAudit(%q): %v", kw, err) + } + if len(items) != 1 { + t.Fatalf("keyword %q expected 1 entry, got %#v", kw, items) + } + if items[0].RiskAttribution == nil || items[0].RiskAttribution.RuleID != "rule_delete" { + t.Fatalf("keyword %q expected rule_delete attribution, got %#v", kw, items[0].RiskAttribution) + } + }) + } +} + +func TestAppListAgentAuditKeywordMatchesPolicyAttribution(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + app := &App{cfg: Config{DataPath: dataPath}} + + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)) + identity, err := identityStore.CreateManual("warehouse-bot") + if err != nil { + t.Fatalf("CreateManual: %v", err) + } + + // Policy-source attribution (e.g. create_datasource is always + // approval-required by protocol). The card shows source=policy with no + // rule id, so keyword search must reach the source bucket. + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(dataPath)) + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: identity.AccessKey, + Protocol: "mcp", + ToolName: "create_datasource", + Summary: "Create datasource warehouse", + Status: agentaudit.StatusApprovalRequired, + ExecutedAt: "2026-04-23T10:00:00Z", + RiskAttribution: &agentaudit.RiskAttribution{ + Source: agentaudit.AttributionSourcePolicy, + Action: "require_approval", + }, + }); err != nil { + t.Fatalf("Append: %v", err) + } + + items, err := app.ListAgentAudit(AgentAuditFilterPayload{Keyword: "policy", Limit: 50}) + if err != nil { + t.Fatalf("ListAgentAudit: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected policy-attributed entry to surface for keyword=policy, got %#v", items) + } + if items[0].RiskAttribution == nil || items[0].RiskAttribution.Source != agentaudit.AttributionSourcePolicy { + t.Fatalf("expected policy attribution, got %#v", items[0].RiskAttribution) + } +} + +func TestAppListAgentAuditReturnsFullStatement(t *testing.T) { + dataPath := filepath.Join(t.TempDir(), "datasources.json") + app := &App{cfg: Config{DataPath: dataPath}} + + identityStore := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(dataPath)) + identity, err := identityStore.CreateManual("warehouse-bot") + if err != nil { + t.Fatalf("CreateManual: %v", err) + } + + statement := "SELECT id, email\nFROM users\nORDER BY id DESC\nLIMIT 50" + auditStore := agentaudit.NewAuditStore(bootstrap.AgentAuditPath(dataPath)) + if err := auditStore.Append(agentaudit.AuditEntry{ + AccessKey: identity.AccessKey, + Protocol: "skill", + ToolName: "execute_statement", + Summary: "SELECT id, email", + Statement: statement, + Status: agentaudit.StatusSuccess, + ExecutedAt: "2026-04-23T10:00:00Z", + }); err != nil { + t.Fatalf("Append: %v", err) + } + + items, err := app.ListAgentAudit(AgentAuditFilterPayload{Keyword: "order by id desc", Limit: 50}) + if err != nil { + t.Fatalf("ListAgentAudit: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 filtered audit entry, got %#v", items) + } + if items[0].Statement != statement { + t.Fatalf("statement = %q, want %q", items[0].Statement, statement) + } +} diff --git a/app_logs.go b/app_logs.go new file mode 100644 index 0000000..8c9b3fa --- /dev/null +++ b/app_logs.go @@ -0,0 +1,191 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" + + "futrixdata/platform/internal/observability" +) + +const ( + defaultLogsMaxBytes int64 = 50 * 1024 * 1024 + defaultRotateMaxBytes int64 = 5 * 1024 * 1024 +) + +func resolveLogsRoot(cfg Config) string { + return filepath.Join(filepath.Dir(cfg.DataPath), "logs") +} + +func newAppLogger(root, fileName string) *log.Logger { + return log.New(observability.NewLevelWriter(observability.Config{ + RootDir: root, + FileName: fileName, + MaxBytes: defaultLogsMaxBytes, + RotateBytes: defaultRotateMaxBytes, + }), "", log.LstdFlags|log.Lmicroseconds) +} + +func configureProcessInfoLog(cfg Config) { + root := resolveLogsRoot(cfg) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.SetOutput(observability.NewLevelWriter(observability.Config{ + RootDir: root, + FileName: "info.log", + MaxBytes: defaultLogsMaxBytes, + RotateBytes: defaultRotateMaxBytes, + })) +} + +func writeProcessErrorLog(cfg Config, format string, args ...any) { + logger := newAppLogger(resolveLogsRoot(cfg), "error.log") + logger.Printf(format, args...) +} + +func (a *App) RecordClientError(kind, message, detail string) error { + if a == nil { + return os.ErrInvalid + } + logger := a.errorLog + if logger == nil { + logger = newAppLogger(a.logsRoot, "error.log") + a.errorLog = logger + } + logger.Printf("level=error source=client kind=%s message=%s detail=%s", + logField(kind), + logField(message), + logField(detail), + ) + return nil +} + +func (a *App) ExportLogs() (string, error) { + if a == nil { + return "", os.ErrInvalid + } + root := strings.TrimSpace(a.logsRoot) + if root == "" { + root = resolveLogsRoot(a.cfg) + } + exportDir, err := resolveExportDirectory() + if err != nil { + return "", err + } + if err := os.MkdirAll(exportDir, 0o755); err != nil { + return "", err + } + name := fmt.Sprintf("futrixdata-logs-%s.zip", time.Now().UTC().Format("20060102T150405")) + target, err := nextExportFilePath(exportDir, name) + if err != nil { + return "", err + } + + file, err := os.Create(target) + if err != nil { + return "", err + } + defer file.Close() + + zipWriter := zip.NewWriter(file) + if err := writeLogManifest(zipWriter, root); err != nil { + zipWriter.Close() + return "", err + } + if err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + if os.IsNotExist(walkErr) { + return nil + } + return walkErr + } + if info.IsDir() { + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + src, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer src.Close() + w, err := zipWriter.Create(rel) + if err != nil { + return err + } + _, err = io.Copy(w, src) + return err + }); err != nil { + zipWriter.Close() + return "", err + } + if err := zipWriter.Close(); err != nil { + a.logErrorf("source=export event=export_logs_failed error=%s", logField(err.Error())) + return "", err + } + a.logInfof("source=export event=export_logs_success path=%s", logField(target)) + return target, nil +} + +func writeLogManifest(zipWriter *zip.Writer, root string) error { + entry, err := zipWriter.Create("manifest.json") + if err != nil { + return err + } + payload, err := json.Marshal(map[string]any{ + "exportedAt": time.Now().UTC().Format(time.RFC3339Nano), + "logRoot": root, + }) + if err != nil { + return err + } + _, err = entry.Write(payload) + return err +} + +func logField(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return `""` + } + return strconvQuote(trimmed) +} + +func strconvQuote(value string) string { + raw, _ := json.Marshal(value) + return string(raw) +} + +func (a *App) logInfof(format string, args ...any) { + if a != nil && a.infoLog != nil { + a.infoLog.Printf(format, args...) + } +} + +func (a *App) logErrorf(format string, args ...any) { + if a != nil && a.errorLog != nil { + a.errorLog.Printf(format, args...) + } +} + +func parseSessionStartedAt(value string) time.Time { + startedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)) + if err != nil { + return time.Time{} + } + return startedAt +} diff --git a/app_logs_test.go b/app_logs_test.go new file mode 100644 index 0000000..a40c3b6 --- /dev/null +++ b/app_logs_test.go @@ -0,0 +1,280 @@ +package main + +import ( + "archive/zip" + "context" + "errors" + "io" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/diagnostics" + "futrixdata/platform/internal/observability" +) + +func TestRecordClientErrorWritesErrorLog(t *testing.T) { + root := t.TempDir() + app := &App{ + errorLog: log.New(observability.NewLevelWriter(observability.Config{ + RootDir: root, + FileName: "error.log", + MaxBytes: 1024, + }), "", 0), + } + + if err := app.RecordClientError("error", "boom", "stack"); err != nil { + t.Fatalf("record client error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(root, "error.log")) + if err != nil { + t.Fatalf("read error.log: %v", err) + } + content := string(data) + if !strings.Contains(content, "source=client") || !strings.Contains(content, `message="boom"`) { + t.Fatalf("unexpected error log content: %q", content) + } +} + +func TestDiagnosticsSettingsTogglePersists(t *testing.T) { + root := t.TempDir() + app := &App{ + diagnostics: diagnostics.NewStore(filepath.Join(root, "diagnostics-settings.json")), + infoLog: log.New(observability.NewLevelWriter(observability.Config{ + RootDir: root, + FileName: "info.log", + MaxBytes: 1024, + }), "", 0), + } + + settings, err := app.GetDiagnosticsSettings() + if err != nil { + t.Fatalf("GetDiagnosticsSettings: %v", err) + } + if settings.DatasourceTimingLogEnabled { + t.Fatal("default DatasourceTimingLogEnabled = true, want false") + } + + settings, err = app.SetDatasourceTimingLogEnabled(true) + if err != nil { + t.Fatalf("SetDatasourceTimingLogEnabled: %v", err) + } + if !settings.DatasourceTimingLogEnabled { + t.Fatal("DatasourceTimingLogEnabled = false, want true") + } + + data, err := os.ReadFile(filepath.Join(root, "info.log")) + if err != nil { + t.Fatalf("read info.log: %v", err) + } + if !strings.Contains(string(data), "event=datasource_timing_log_toggled enabled=true") { + t.Fatalf("expected toggle log, got %q", string(data)) + } +} + +func TestDatasourceTimingFinishLogsErrorDetails(t *testing.T) { + root := t.TempDir() + store := diagnostics.NewStore(filepath.Join(root, "diagnostics-settings.json")) + if _, err := store.SetDatasourceTimingLogEnabled(true); err != nil { + t.Fatalf("enable datasource timing: %v", err) + } + logger := log.New(observability.NewLevelWriter(observability.Config{ + RootDir: root, + FileName: "info.log", + MaxBytes: 1024, + }), "", 0) + + _, finish := newAppDatasourceTimingStarter(store, logger)( + context.Background(), + "app.test_datasource", + datasource.DataSource{ID: "d1-prod", Type: datasource.TypeD1}, + "", + console.ExecuteOptions{}, + false, + ) + finish(errors.New("dial tcp 127.0.0.1:443: i/o timeout")) + + data, err := os.ReadFile(filepath.Join(root, "info.log")) + if err != nil { + t.Fatalf("read info.log: %v", err) + } + content := string(data) + for _, want := range []string{`event="finish"`, `status="error"`, `error_kind="error"`, `error_message="dial tcp 127.0.0.1:443: i/o timeout"`} { + if !strings.Contains(content, want) { + t.Fatalf("expected %q in datasource timing finish log:\n%s", want, content) + } + } +} + +func TestExportLogsBundlesExpectedFiles(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "info.log"), []byte("info"), 0o644); err != nil { + t.Fatalf("write info.log: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "error.log"), []byte("error"), 0o644); err != nil { + t.Fatalf("write error.log: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "aichat"), 0o755); err != nil { + t.Fatalf("mkdir aichat: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "aichat", "2026-03-10.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("write aichat log: %v", err) + } + + app := &App{logsRoot: root} + zipPath, err := app.ExportLogs() + if err != nil { + t.Fatalf("export logs: %v", err) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + defer reader.Close() + + var names []string + for _, file := range reader.File { + names = append(names, file.Name) + if file.Name == "manifest.json" { + rc, err := file.Open() + if err != nil { + t.Fatalf("open manifest: %v", err) + } + body, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if !strings.Contains(string(body), `"logRoot"`) { + t.Fatalf("expected manifest content, got %q", string(body)) + } + } + } + + joined := strings.Join(names, "\n") + if !strings.Contains(joined, "info.log") || !strings.Contains(joined, "error.log") || !strings.Contains(joined, "aichat/2026-03-10.jsonl") { + t.Fatalf("unexpected zip entries: %v", names) + } +} + +func TestExportLogsSkipsVanishedFiles(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "info.log"), []byte("info"), 0o644); err != nil { + t.Fatalf("write info.log: %v", err) + } + broken := filepath.Join(root, "missing.log") + if err := os.Symlink(filepath.Join(root, "rotated-away.log"), broken); err != nil { + t.Fatalf("create broken symlink: %v", err) + } + + app := &App{logsRoot: root} + zipPath, err := app.ExportLogs() + if err != nil { + t.Fatalf("export logs with vanished file: %v", err) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + defer reader.Close() + + for _, file := range reader.File { + if file.Name == "missing.log" { + t.Fatalf("expected vanished file to be skipped from archive") + } + } +} + +func TestExportLogsSucceedsWhenLogRootDoesNotExist(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + root := filepath.Join(t.TempDir(), "missing-logs") + app := &App{logsRoot: root} + + zipPath, err := app.ExportLogs() + if err != nil { + t.Fatalf("export logs without log root: %v", err) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + defer reader.Close() + + var names []string + for _, file := range reader.File { + names = append(names, file.Name) + } + + if len(names) != 1 || names[0] != "manifest.json" { + t.Fatalf("expected manifest-only archive, got %v", names) + } +} + +func TestExportLogsSkipsSymlinkTargets(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + downloads := filepath.Join(tmpHome, "Downloads") + if err := os.MkdirAll(downloads, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "info.log"), []byte("info"), 0o644); err != nil { + t.Fatalf("write info.log: %v", err) + } + + outsideDir := t.TempDir() + outsidePath := filepath.Join(outsideDir, "secret.txt") + if err := os.WriteFile(outsidePath, []byte("secret"), 0o644); err != nil { + t.Fatalf("write outside file: %v", err) + } + if err := os.Symlink(outsidePath, filepath.Join(root, "outside.log")); err != nil { + t.Fatalf("create symlink: %v", err) + } + + app := &App{logsRoot: root} + zipPath, err := app.ExportLogs() + if err != nil { + t.Fatalf("export logs with symlink: %v", err) + } + + reader, err := zip.OpenReader(zipPath) + if err != nil { + t.Fatalf("open zip: %v", err) + } + defer reader.Close() + + for _, file := range reader.File { + if file.Name == "outside.log" { + t.Fatalf("expected symlink target to be skipped from archive") + } + } +} diff --git a/app_plan.go b/app_plan.go new file mode 100644 index 0000000..aaaa84e --- /dev/null +++ b/app_plan.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "time" + + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/planlimits" +) + +func (a *App) currentPlan() (string, bool) { + if a == nil || a.authStore == nil { + return "", false + } + state := a.authStore.Current() + if state.Session == nil { + return planlimits.EffectivePlanWithTrial("", "", 0, trialExpiresAt(state), time.Now()), true + } + return effectivePlanForState(state, time.Now()), true +} + +func effectivePlanForState(state auth.State, now time.Time) string { + if state.Session == nil { + return planlimits.EffectivePlanWithTrial("", "", 0, trialExpiresAt(state), now) + } + license := state.Session.License + return planlimits.EffectivePlanWithTrial(license.Plan, license.Status, license.ExpiresAt, trialExpiresAt(state), now) +} + +func trialExpiresAt(state auth.State) int64 { + if state.Trial == nil { + return 0 + } + return state.Trial.ExpiresAt +} + +func (a *App) ensureDatasourceCreateAllowed() error { + check := a.datasourceCreateCheck() + if check == nil || a == nil || a.store == nil { + return nil + } + return check(len(a.store.List())) +} + +func (a *App) datasourceCreateCheck() func(count int) error { + plan, ok := a.currentPlan() + if !ok || a == nil || a.store == nil { + return nil + } + limit := planlimits.DatasourceLimit(plan) + if limit <= 0 { + return nil + } + return func(count int) error { + if count >= limit { + return errors.New(planlimits.DatasourceLimitError(plan)) + } + return nil + } +} + +func (a *App) ensureCustomRiskRulesAllowed() error { + if a.riskRulesAuthenticated() { + return nil + } + plan, ok := a.currentPlan() + if ok && planlimits.PolicyManagementAllowed(plan) { + return nil + } + return auth.ErrLoginRequired +} + +func (a *App) ensureBuiltinRiskRulesAllowed() error { + plan, ok := a.currentPlan() + if ok && planlimits.PolicyManagementAllowed(plan) { + return nil + } + if !a.riskRulesAuthenticated() { + return auth.ErrLoginRequired + } + if ok { + return errors.New(planlimits.CustomRiskRulesError(plan)) + } + return nil +} + +func (a *App) riskRulesAuthenticated() bool { + if a == nil || a.authStore == nil { + return false + } + if a.authStore.Current().Session == nil { + return false + } + return true +} diff --git a/app_plan_limits_test.go b/app_plan_limits_test.go new file mode 100644 index 0000000..5c1507e --- /dev/null +++ b/app_plan_limits_test.go @@ -0,0 +1,490 @@ +package main + +import ( + "path/filepath" + "testing" + "time" + + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/riskengine" + "futrixdata/platform/internal/sensitivity" +) + +func newPlanLimitsTestApp(t *testing.T, plan string) *App { + t.Helper() + return newPlanLimitsTestAppWithLicense(t, auth.License{Plan: plan, Status: "active"}) +} + +func newPlanLimitsTestAppWithLicense(t *testing.T, license auth.License) *App { + t.Helper() + + dataPath := filepath.Join(t.TempDir(), "datasources.json") + dsStore := datasource.NewStore(dataPath) + if err := dsStore.Load(); err != nil { + t.Fatalf("load datasource store: %v", err) + } + + authStore := auth.NewStore(filepath.Join(t.TempDir(), "auth-session.json")) + if err := authStore.Load(); err != nil { + t.Fatalf("load auth store: %v", err) + } + state := authStore.Current() + state.Trial = expiredLocalTrial() + state.Session = &auth.Session{ + AccessToken: "access_token", + RefreshToken: "refresh_token", + User: auth.User{ + ID: "user_1", + Email: "user@example.com", + DisplayName: "Plan User", + }, + License: license, + } + if err := authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + riskStore := riskengine.NewStore(filepath.Join(t.TempDir(), "risk-rules")) + if err := riskStore.Load(); err != nil { + t.Fatalf("load risk store: %v", err) + } + riskEngine := riskengine.NewEngine() + riskEngine.ReloadFromStore(riskStore) + + return &App{ + cfg: Config{DataPath: dataPath}, + store: dsStore, + authStore: authStore, + riskStore: riskStore, + riskEngine: riskEngine, + } +} + +func newPlanLimitsTestAppWithoutSession(t *testing.T) *App { + t.Helper() + + dataPath := filepath.Join(t.TempDir(), "datasources.json") + dsStore := datasource.NewStore(dataPath) + if err := dsStore.Load(); err != nil { + t.Fatalf("load datasource store: %v", err) + } + + authStore := auth.NewStore(filepath.Join(t.TempDir(), "auth-session.json")) + if err := authStore.Load(); err != nil { + t.Fatalf("load auth store: %v", err) + } + state := authStore.Current() + state.Trial = expiredLocalTrial() + if err := authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + riskStore := riskengine.NewStore(filepath.Join(t.TempDir(), "risk-rules")) + if err := riskStore.Load(); err != nil { + t.Fatalf("load risk store: %v", err) + } + riskEngine := riskengine.NewEngine() + riskEngine.ReloadFromStore(riskStore) + + return &App{ + cfg: Config{DataPath: dataPath}, + store: dsStore, + authStore: authStore, + riskStore: riskStore, + riskEngine: riskEngine, + } +} + +func attachSensitivityManager(t *testing.T, app *App) { + t.Helper() + store := sensitivity.NewStore(filepath.Join(t.TempDir(), "sensitivity.json")) + if err := store.Load(); err != nil { + t.Fatalf("load sensitivity store: %v", err) + } + app.sensitivityMgr = sensitivity.NewManager(store, nil) +} + +func addTestDatasource(t *testing.T, app *App, id string) { + t.Helper() + if app == nil || app.store == nil { + t.Fatalf("app store not initialized") + } + if _, err := app.store.Create(datasource.DataSource{ + ID: id, + Name: id, + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + Username: "root", + Database: "mysql", + }); err != nil { + t.Fatalf("seed datasource %s: %v", id, err) + } +} + +func TestCurrentPlan_LoggedOutResolvesToFree(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + + plan, ok := app.currentPlan() + if !ok { + t.Fatalf("expected logged-out app to resolve a plan") + } + if plan != "free" { + t.Fatalf("expected logged-out app to resolve to free, got %q", plan) + } +} + +func TestCurrentPlan_LoggedOutActiveTrialResolvesToPro(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + state := app.authStore.Current() + state.Trial = activeLocalTrial() + if err := app.authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + plan, ok := app.currentPlan() + if !ok { + t.Fatalf("expected logged-out app to resolve a plan") + } + if plan != "pro" { + t.Fatalf("expected logged-out active trial to resolve to pro, got %q", plan) + } +} + +func TestCreateDatasource_AllowsLoggedOutActiveTrialBeyondThreeDatasources(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + state := app.authStore.Current() + state.Trial = activeLocalTrial() + if err := app.authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + addTestDatasource(t, app, "ds_1") + addTestDatasource(t, app, "ds_2") + addTestDatasource(t, app, "ds_3") + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "ds_4", + Type: datasource.TypePostgreSQL, + Host: "127.0.0.1", + Port: 5432, + Username: "postgres", + Database: "postgres", + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + if created.Name != "ds_4" { + t.Fatalf("expected ds_4 to be created, got %#v", created) + } +} + +func TestCreateDatasource_BlocksLoggedOutAfterThreeDatasources(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + addTestDatasource(t, app, "ds_1") + addTestDatasource(t, app, "ds_2") + addTestDatasource(t, app, "ds_3") + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "ds_4", + Type: datasource.TypePostgreSQL, + Host: "127.0.0.1", + Port: 5432, + Username: "postgres", + Database: "postgres", + }) + if err == nil { + t.Fatalf("expected logged-out datasource limit error") + } + if err.Error() != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %v", err) + } +} + +func TestCreateDatasource_BlocksFreePlanAfterThreeDatasources(t *testing.T) { + app := newPlanLimitsTestApp(t, "free") + addTestDatasource(t, app, "ds_1") + addTestDatasource(t, app, "ds_2") + addTestDatasource(t, app, "ds_3") + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "ds_4", + Type: datasource.TypePostgreSQL, + Host: "127.0.0.1", + Port: 5432, + Username: "postgres", + Database: "postgres", + }) + if err == nil { + t.Fatalf("expected free plan datasource limit error") + } + if err.Error() != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected stable datasource limit error, got %v", err) + } +} + +func TestCreateDatasource_AllowsProPlanBeyondThreeDatasources(t *testing.T) { + app := newPlanLimitsTestApp(t, "pro") + addTestDatasource(t, app, "ds_1") + addTestDatasource(t, app, "ds_2") + addTestDatasource(t, app, "ds_3") + + created, err := app.CreateDatasource(DataSourcePayload{ + Name: "ds_4", + Type: datasource.TypePostgreSQL, + Host: "127.0.0.1", + Port: 5432, + Username: "postgres", + Database: "postgres", + }) + if err != nil { + t.Fatalf("CreateDatasource: %v", err) + } + if created.Name != "ds_4" { + t.Fatalf("expected ds_4 to be created, got %#v", created) + } +} + +func TestRiskEngineAddRule_AllowsFreePlanCustomRules(t *testing.T) { + app := newPlanLimitsTestApp(t, "free") + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "user-test-rule", + Description: "test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err != nil { + t.Fatalf("RiskEngineAddRule: %v", err) + } + rules := app.RiskEngineListUserRules() + if len(rules) != 1 || rules[0].ID != "user-test-rule" { + t.Fatalf("expected saved custom rule, got %#v", rules) + } +} + +func TestRiskEngineAddRule_BlocksLoggedOutCustomRules(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "guest-test-rule", + Description: "guest test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err == nil { + t.Fatalf("expected logged-out risk-rule error") + } + if err.Error() != "login required" { + t.Fatalf("expected login-required risk-rule error, got %v", err) + } +} + +func TestRiskEngineAddRule_AllowsLoggedOutActiveTrialCustomRules(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + state := app.authStore.Current() + state.Trial = activeLocalTrial() + if err := app.authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "guest-trial-rule", + Description: "guest trial rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err != nil { + t.Fatalf("RiskEngineAddRule: %v", err) + } + if _, ok := app.riskStore.Get("guest-trial-rule"); !ok { + t.Fatalf("expected active trial to save logged-out custom rule") + } +} + +func TestRiskEngineSetBuiltinEnabled_AllowsLoggedOutActiveTrialBuiltinRules(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + state := app.authStore.Current() + state.Trial = activeLocalTrial() + if err := app.authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + err := app.RiskEngineSetBuiltinEnabled("sql-allow-insert", true) + if err != nil { + t.Fatalf("RiskEngineSetBuiltinEnabled: %v", err) + } +} + +func TestSensitivitySetCustomRules_BlocksLoggedOutExpiredTrial(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + attachSensitivityManager(t, app) + + resp := app.SensitivitySetCustomRules("mask email") + if resp["error"] != auth.ErrLoginRequired.Error() { + t.Fatalf("expected login-required sensitivity-rule error, got %#v", resp) + } +} + +func TestSensitivitySetCustomRules_AllowsLoggedOutActiveTrial(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + attachSensitivityManager(t, app) + state := app.authStore.Current() + state.Trial = activeLocalTrial() + if err := app.authStore.Save(state); err != nil { + t.Fatalf("save auth store: %v", err) + } + + resp := app.SensitivitySetCustomRules("mask email") + if resp["error"] != nil { + t.Fatalf("expected active trial to save sensitivity rules, got %#v", resp) + } + if got := app.sensitivityMgr.Store().GetCustomRules(); got != "mask email" { + t.Fatalf("saved custom rules = %q, want %q", got, "mask email") + } +} + +func TestRiskEngineSetEnabled_BlocksLoggedOutCustomRules(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + if err := app.riskStore.Create(riskengine.Rule{ + ID: "guest-test-rule", + Description: "guest test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }); err != nil { + t.Fatalf("seed risk rule: %v", err) + } + + err := app.RiskEngineSetEnabled("guest-test-rule", false) + if err == nil { + t.Fatalf("expected logged-out risk-rule error") + } + if err.Error() != "login required" { + t.Fatalf("expected login-required risk-rule error, got %v", err) + } +} + +func TestRiskEngineDeleteRule_BlocksLoggedOutCustomRules(t *testing.T) { + app := newPlanLimitsTestAppWithoutSession(t) + if err := app.riskStore.Create(riskengine.Rule{ + ID: "guest-test-rule", + Description: "guest test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }); err != nil { + t.Fatalf("seed risk rule: %v", err) + } + + err := app.RiskEngineDeleteRule("guest-test-rule") + if err == nil { + t.Fatalf("expected logged-out risk-rule delete error") + } + if err.Error() != "login required" { + t.Fatalf("expected login-required risk-rule delete error, got %v", err) + } + if _, ok := app.riskStore.Get("guest-test-rule"); !ok { + t.Fatalf("expected logged-out delete attempt to keep the custom rule") + } +} + +func TestRiskEngineUpdateRule_AllowsFreePlanCustomRules(t *testing.T) { + app := newPlanLimitsTestApp(t, "free") + if err := app.riskStore.Create(riskengine.Rule{ + ID: "user-test-rule", + Description: "test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }); err != nil { + t.Fatalf("seed risk rule: %v", err) + } + + err := app.RiskEngineUpdateRule("user-test-rule", riskengine.Rule{ + Description: "updated rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err != nil { + t.Fatalf("RiskEngineUpdateRule: %v", err) + } + rule, ok := app.riskStore.Get("user-test-rule") + if !ok || rule.Description != "updated rule" { + t.Fatalf("expected updated custom rule, got %#v ok=%v", rule, ok) + } +} + +func TestRiskEngineAddRule_AllowsProPlanCustomRules(t *testing.T) { + app := newPlanLimitsTestApp(t, "pro") + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "user-test-rule", + Description: "test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err != nil { + t.Fatalf("RiskEngineAddRule: %v", err) + } + rules := app.RiskEngineListUserRules() + if len(rules) != 1 || rules[0].ID != "user-test-rule" { + t.Fatalf("expected saved custom rule, got %#v", rules) + } +} + +// Expired-pro sessions must be treated as Free for plan-limit gates regardless +// of the historical License.Plan value. The local session may still carry +// plan=pro until the next refresh, but App.currentPlan must resolve effective. +func TestCurrentPlan_ExpiredProResolvesToFree(t *testing.T) { + pastExpiry := time.Now().Add(-time.Hour).Unix() + app := newPlanLimitsTestAppWithLicense(t, auth.License{ + Plan: "pro", + Status: "expired", + ExpiresAt: pastExpiry, + }) + plan, ok := app.currentPlan() + if !ok { + t.Fatalf("expected currentPlan to resolve, got ok=false") + } + if plan != "free" { + t.Fatalf("expected expired-pro session to resolve to free, got %q", plan) + } +} + +func TestCreateDatasource_BlocksExpiredProAfterThreeDatasources(t *testing.T) { + pastExpiry := time.Now().Add(-time.Hour).Unix() + app := newPlanLimitsTestAppWithLicense(t, auth.License{ + Plan: "pro", + Status: "active", + ExpiresAt: pastExpiry, + }) + addTestDatasource(t, app, "ds_1") + addTestDatasource(t, app, "ds_2") + addTestDatasource(t, app, "ds_3") + + _, err := app.CreateDatasource(DataSourcePayload{ + Name: "ds_4", + Type: datasource.TypePostgreSQL, + Host: "127.0.0.1", + Port: 5432, + Username: "postgres", + Database: "postgres", + }) + if err == nil { + t.Fatalf("expected expired-pro session to be limited like free") + } + if err.Error() != "plan_limit_exceeded:datasources:free:3" { + t.Fatalf("expected free-plan datasource limit error, got %v", err) + } +} + +func TestRiskEngineAddRule_AllowsExpiredProCustomRulesBecauseSessionIsSignedIn(t *testing.T) { + app := newPlanLimitsTestAppWithLicense(t, auth.License{ + Plan: "pro", + Status: "expired", + }) + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "user-test-rule", + Description: "test rule", + Action: riskengine.ActionWarn, + Enabled: true, + }) + if err != nil { + t.Fatalf("RiskEngineAddRule: %v", err) + } +} diff --git a/app_recovery.go b/app_recovery.go new file mode 100644 index 0000000..68a4fa7 --- /dev/null +++ b/app_recovery.go @@ -0,0 +1,279 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/daemon" + "futrixdata/platform/internal/ipc" + "futrixdata/platform/internal/localcrypto" + "futrixdata/platform/internal/startuprecovery" + + "github.com/pkg/browser" + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + startupStateInitializing = "initializing" + startupStateReady = "ready" + startupStateFailed = "failed" +) + +type StartupRecoveryStatus struct { + State string `json:"state"` + Error *startuprecovery.Info `json:"error,omitempty"` + MovedAside *StartupRecoveryMoveAsideState `json:"movedAside,omitempty"` +} + +type StartupRecoveryMoveAsideState struct { + RetentionDir string `json:"retentionDir,omitempty"` +} + +func NewAppShell(cfg Config) *App { + cfg.DataPath = bootstrap.ResolveDataPath(cfg.DataPath) + logsRoot := resolveLogsRoot(cfg) + return &App{ + cfg: cfg, + logsRoot: logsRoot, + infoLog: newAppLogger(logsRoot, "info.log"), + errorLog: newAppLogger(logsRoot, "error.log"), + startupState: startupStateInitializing, + } +} + +func (a *App) startRuntimeInitialization() { + a.setStartupInitializing() + go func() { + if err := a.initializeRuntime(context.Background()); err != nil { + a.setStartupFailed(err) + return + } + a.setStartupReady() + a.runtimeStartupReady(a.ctx) + }() +} + +func (a *App) initializeRuntime(ctx context.Context) error { + full, err := NewApp(a.cfg) + if err != nil { + writeProcessErrorLog(a.cfg, "source=startup event=init_app_failed error=%s", logField(err.Error())) + return err + } + a.installRuntime(full) + if err := a.startEmbeddedDaemon(ctx); err != nil { + writeProcessErrorLog(a.cfg, "source=startup event=embedded_daemon_failed error=%s", logField(err.Error())) + return err + } + return nil +} + +func (a *App) installRuntime(full *App) { + a.startupMu.Lock() + defer a.startupMu.Unlock() + + ctx := a.ctx + emitEvent := a.emitEvent + launchArgs := append([]string(nil), a.launchArgs...) + state := a.startupState + startupErr := a.startupError + movedAside := a.movedAside + daemonCancel := a.daemonCancel + daemonDone := a.daemonDone + + a.cfg = full.cfg + a.store = full.store + a.aiConfigStore = full.aiConfigStore + a.authStore = full.authStore + a.authService = full.authService + a.updaterService = full.updaterService + a.schemaKB = full.schemaKB + a.aiChat = full.aiChat + a.aiChatDiag = full.aiChatDiag + a.aiChatStreams = full.aiChatStreams + a.manager = full.manager + a.riskEngine = full.riskEngine + a.riskStore = full.riskStore + a.redisDocs = full.redisDocs + a.entityCache = full.entityCache + a.historyStore = full.historyStore + a.redisProtoStore = full.redisProtoStore + a.datasourceSecrets = full.datasourceSecrets + a.secretConfigs = full.secretConfigs + a.fallbackAI = full.fallbackAI + a.userKB = full.userKB + a.sensitivityMgr = full.sensitivityMgr + a.schemaPrivacy = full.schemaPrivacy + a.toolService = full.toolService + a.runCommand = full.runCommand + a.httpClient = full.httpClient + a.logsRoot = full.logsRoot + a.infoLog = full.infoLog + a.errorLog = full.errorLog + a.diagnostics = full.diagnostics + a.sessionTracker = full.sessionTracker + + a.ctx = ctx + a.emitEvent = emitEvent + a.launchArgs = launchArgs + a.startupState = state + a.startupError = startupErr + a.movedAside = movedAside + a.daemonCancel = daemonCancel + a.daemonDone = daemonDone +} + +func (a *App) startEmbeddedDaemon(ctx context.Context) error { + if a.toolService == nil { + return errors.New("tool service is not configured") + } + skipEmbedded, err := tryDaemonHandoff(a.cfg.DataPath, 5*time.Second) + if err != nil { + return fmt.Errorf("daemon handoff failed (another instance still owns the IPC socket): %w", err) + } + if skipEmbedded { + return nil + } + + daemonCtx, daemonCancel := context.WithCancel(ctx) + daemonDone := make(chan struct{}) + daemonReady := make(chan error, 1) + go func() { + defer close(daemonDone) + derr := daemon.Run(daemonCtx, daemon.Config{ + DataPath: a.cfg.DataPath, + AuthBaseURL: a.cfg.AuthBaseURL, + Service: a.toolService, + Mode: ipc.HandshakeModeGUI, + SkipSignals: true, + Ready: daemonReady, + }) + if derr != nil && !errors.Is(derr, context.Canceled) { + a.logErrorf("source=daemon event=embedded_daemon_failed error=%s", logField(derr.Error())) + } + }() + if rerr := <-daemonReady; rerr != nil { + daemonCancel() + <-daemonDone + return rerr + } + a.startupMu.Lock() + a.daemonCancel = daemonCancel + a.daemonDone = daemonDone + a.startupMu.Unlock() + return nil +} + +func (a *App) stopEmbeddedDaemon() { + a.startupMu.Lock() + cancel := a.daemonCancel + done := a.daemonDone + a.daemonCancel = nil + a.daemonDone = nil + a.startupMu.Unlock() + if cancel != nil { + cancel() + } + if done != nil { + <-done + } +} + +func (a *App) setStartupInitializing() { + a.startupMu.Lock() + a.startupState = startupStateInitializing + a.startupError = nil + a.startupMu.Unlock() + a.emitStartupRecoveryStatus() +} + +func (a *App) setStartupReady() { + a.startupMu.Lock() + a.startupState = startupStateReady + a.startupError = nil + a.startupMu.Unlock() + a.emitStartupRecoveryStatus() +} + +func (a *App) setStartupFailed(err error) { + info := startuprecovery.Classify(err, a.cfg.DataPath) + info.DataPath = a.cfg.DataPath + info.DataDir = filepath.Dir(a.cfg.DataPath) + a.startupMu.Lock() + a.startupState = startupStateFailed + a.startupError = &info + a.startupMu.Unlock() + a.emitStartupRecoveryStatus() +} + +func (a *App) StartupRecoveryStatus() StartupRecoveryStatus { + a.startupMu.RLock() + defer a.startupMu.RUnlock() + status := StartupRecoveryStatus{State: a.startupState} + if status.State == "" { + status.State = startupStateInitializing + } + if a.startupError != nil { + cp := *a.startupError + status.Error = &cp + } + if a.movedAside != nil && strings.TrimSpace(a.movedAside.RetentionDir) != "" { + status.MovedAside = &StartupRecoveryMoveAsideState{RetentionDir: a.movedAside.RetentionDir} + } + return status +} + +func (a *App) StartupRecoveryRetry() (StartupRecoveryStatus, error) { + a.stopEmbeddedDaemon() + a.setStartupInitializing() + if err := a.initializeRuntime(context.Background()); err != nil { + a.setStartupFailed(err) + return a.StartupRecoveryStatus(), nil + } + a.setStartupReady() + a.runtimeStartupReady(a.ctx) + return a.StartupRecoveryStatus(), nil +} + +func (a *App) StartupRecoveryOpenLogs() error { + root := strings.TrimSpace(a.logsRoot) + if root == "" { + root = resolveLogsRoot(a.cfg) + } + if err := os.MkdirAll(root, 0o755); err != nil { + return err + } + return browser.OpenFile(root) +} + +func (a *App) StartupRecoveryOpenUpdatePage() error { + return browser.OpenURL("https://futrixdata.com") +} + +func (a *App) StartupRecoveryMoveAsideAndRestart(confirmed bool) (StartupRecoveryStatus, error) { + result, err := localcrypto.MoveAsideUnrecoverableData(a.cfg.DataPath, confirmed) + if err != nil { + return a.StartupRecoveryStatus(), err + } + a.startupMu.Lock() + a.movedAside = &result + a.startupMu.Unlock() + return a.StartupRecoveryRetry() +} + +func (a *App) emitStartupRecoveryStatus() { + if a == nil || a.ctx == nil { + return + } + status := a.StartupRecoveryStatus() + if a.emitEvent != nil { + a.emitEvent(a.ctx, "startup-recovery:status", status) + return + } + wailsruntime.EventsEmit(a.ctx, "startup-recovery:status", status) +} diff --git a/app_recovery_test.go b/app_recovery_test.go new file mode 100644 index 0000000..72ec737 --- /dev/null +++ b/app_recovery_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "path/filepath" + "testing" + + "futrixdata/platform/internal/diagnostics" + "futrixdata/platform/internal/redisproto" +) + +// TestInstallRuntimeCopiesRedisProtoStore guards against the regression that +// caused "redis protobuf store unavailable": installRuntime forgot to copy the +// redisProtoStore pointer from the async-built *App into the shell App, so +// SaveRedisProtobufSchema always saw a nil store at runtime. +func TestInstallRuntimeCopiesRedisProtoStore(t *testing.T) { + shell := &App{} + store := redisproto.NewStore(t.TempDir() + "/redis-protobuf.json") + full := &App{redisProtoStore: store} + + shell.installRuntime(full) + + if shell.redisProtoStore == nil { + t.Fatal("installRuntime did not copy redisProtoStore — SaveRedisProtobufSchema will fail at runtime") + } + if shell.redisProtoStore != store { + t.Fatal("installRuntime copied a different redisProtoStore than the one on full") + } +} + +// TestInstallRuntimeCopiesDiagnosticsStore guards the Wails shell path. Wails +// binds the shell *App before async runtime initialization, so installRuntime +// must copy newly added backend stores back onto that same pointer. +func TestInstallRuntimeCopiesDiagnosticsStore(t *testing.T) { + shell := &App{} + store := diagnostics.NewStore(filepath.Join(t.TempDir(), "diagnostics-settings.json")) + full := &App{diagnostics: store} + + shell.installRuntime(full) + + settings, err := shell.SetDatasourceTimingLogEnabled(true) + if err != nil { + t.Fatalf("SetDatasourceTimingLogEnabled: %v", err) + } + if !settings.DatasourceTimingLogEnabled { + t.Fatal("DatasourceTimingLogEnabled = false after shell installRuntime, want true") + } + if shell.diagnostics != store { + t.Fatal("installRuntime copied a different diagnostics store than the one on full") + } +} diff --git a/app_redisproto.go b/app_redisproto.go new file mode 100644 index 0000000..87bc1c8 --- /dev/null +++ b/app_redisproto.go @@ -0,0 +1,108 @@ +package main + +import ( + "errors" + "strings" + "time" + + "futrixdata/platform/internal/redisproto" +) + +// RedisProtobufSchemaPayload is the wire-format input used by Wails bindings. +// Mirrors redisproto.SaveRequest but lives in main package to keep the JSON +// field names stable for the frontend. +type RedisProtobufSchemaPayload struct { + ID string `json:"id"` + DatasourceID string `json:"datasourceId"` + Name string `json:"name"` + Content string `json:"content"` +} + +// RedisProtobufSchemaView is the Wails-safe schema model exposed to the +// frontend. The store keeps time.Time values, but Wails' TS generator does not +// model time.Time cleanly for nested structs. +type RedisProtobufSchemaView struct { + ID string `json:"id"` + DatasourceID string `json:"datasourceId"` + Name string `json:"name"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func redisProtobufSchemaView(schema redisproto.Schema) RedisProtobufSchemaView { + return RedisProtobufSchemaView{ + ID: schema.ID, + DatasourceID: schema.DatasourceID, + Name: schema.Name, + Content: schema.Content, + CreatedAt: redisProtobufTimestamp(schema.CreatedAt), + UpdatedAt: redisProtobufTimestamp(schema.UpdatedAt), + } +} + +func redisProtobufSchemaViews(schemas []redisproto.Schema) []RedisProtobufSchemaView { + out := make([]RedisProtobufSchemaView, 0, len(schemas)) + for _, schema := range schemas { + out = append(out, redisProtobufSchemaView(schema)) + } + return out +} + +func redisProtobufTimestamp(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} + +func (a *App) ListRedisProtobufSchemas(datasourceID string) ([]RedisProtobufSchemaView, error) { + if a.redisProtoStore == nil { + return []RedisProtobufSchemaView{}, nil + } + id := strings.TrimSpace(datasourceID) + if id == "" { + // Empty selector lists everything — useful for the manage dialog. + return redisProtobufSchemaViews(a.redisProtoStore.List()), nil + } + scoped := a.redisProtoStore.ListByDatasource(id) + global := a.redisProtoStore.ListByDatasource("") + out := make([]redisproto.Schema, 0, len(scoped)+len(global)) + out = append(out, scoped...) + out = append(out, global...) + return redisProtobufSchemaViews(out), nil +} + +func (a *App) GetRedisProtobufSchema(id string) (RedisProtobufSchemaView, error) { + if a.redisProtoStore == nil { + return RedisProtobufSchemaView{}, errors.New("redis protobuf store unavailable") + } + schema, ok := a.redisProtoStore.Get(strings.TrimSpace(id)) + if !ok { + return RedisProtobufSchemaView{}, redisproto.ErrNotFound + } + return redisProtobufSchemaView(schema), nil +} + +func (a *App) SaveRedisProtobufSchema(payload RedisProtobufSchemaPayload) (RedisProtobufSchemaView, error) { + if a.redisProtoStore == nil { + return RedisProtobufSchemaView{}, errors.New("redis protobuf store unavailable") + } + schema, err := a.redisProtoStore.Save(redisproto.SaveRequest{ + ID: strings.TrimSpace(payload.ID), + DatasourceID: strings.TrimSpace(payload.DatasourceID), + Name: payload.Name, + Content: payload.Content, + }) + if err != nil { + return RedisProtobufSchemaView{}, err + } + return redisProtobufSchemaView(schema), nil +} + +func (a *App) DeleteRedisProtobufSchema(id string) (bool, error) { + if a.redisProtoStore == nil { + return false, errors.New("redis protobuf store unavailable") + } + if err := a.redisProtoStore.Delete(strings.TrimSpace(id)); err != nil { + return false, err + } + return true, nil +} diff --git a/app_redisproto_test.go b/app_redisproto_test.go new file mode 100644 index 0000000..f551d1c --- /dev/null +++ b/app_redisproto_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "path/filepath" + "reflect" + "testing" + + "futrixdata/platform/internal/redisproto" +) + +func TestRedisProtobufSchemaWailsViewUsesStringTimestamps(t *testing.T) { + app := &App{ + redisProtoStore: redisproto.NewStore(filepath.Join(t.TempDir(), "redis-protobuf.json")), + } + + schema, err := app.SaveRedisProtobufSchema(RedisProtobufSchemaPayload{ + DatasourceID: "redis_local", + Name: "user.proto", + Content: `syntax = "proto3"; message User { string name = 1; }`, + }) + if err != nil { + t.Fatalf("SaveRedisProtobufSchema returned error: %v", err) + } + + if got := reflect.TypeOf(schema.CreatedAt).Kind(); got != reflect.String { + t.Fatalf("CreatedAt should be a Wails-safe string timestamp, got %s", got) + } + if got := reflect.TypeOf(schema.UpdatedAt).Kind(); got != reflect.String { + t.Fatalf("UpdatedAt should be a Wails-safe string timestamp, got %s", got) + } +} diff --git a/app_riskengine.go b/app_riskengine.go new file mode 100644 index 0000000..c554378 --- /dev/null +++ b/app_riskengine.go @@ -0,0 +1,125 @@ +package main + +import ( + "errors" + + "futrixdata/platform/internal/riskengine" +) + +// RiskEngineListRules returns all risk rules (builtin + user). +func (a *App) RiskEngineListRules() []riskengine.Rule { + if a.riskEngine == nil { + return nil + } + return a.riskEngine.ListAllRules() +} + +// RiskEngineListUserRules returns only user-defined rules. +func (a *App) RiskEngineListUserRules() []riskengine.Rule { + if a.riskStore == nil { + return nil + } + return a.riskStore.List() +} + +// RiskEngineAddRule creates a new user-defined risk rule. +func (a *App) RiskEngineAddRule(rule riskengine.Rule) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureCustomRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.Create(rule); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineUpdateRule updates an existing user-defined risk rule. +func (a *App) RiskEngineUpdateRule(id string, rule riskengine.Rule) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureCustomRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.Update(id, rule); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineDeleteRule removes a user-defined risk rule. +func (a *App) RiskEngineDeleteRule(id string) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureCustomRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.Delete(id); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineSetEnabled enables or disables a user-defined risk rule for the desktop UI only. +func (a *App) RiskEngineSetEnabled(id string, enabled bool) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureCustomRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.SetEnabled(id, enabled); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineSetBuiltinEnabled enables or disables a built-in risk rule for the desktop UI only. +func (a *App) RiskEngineSetBuiltinEnabled(id string, enabled bool) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureBuiltinRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.SetBuiltinEnabled(id, enabled); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineUpdateBuiltinProbeRuleThresholds updates editable thresholds for a built-in probe rule. +func (a *App) RiskEngineUpdateBuiltinProbeRuleThresholds(id string, thresholds riskengine.RuleThresholds) error { + if a.riskStore == nil { + return errors.New("risk store not initialized") + } + if err := a.ensureBuiltinRiskRulesAllowed(); err != nil { + return err + } + if err := a.riskStore.UpdateBuiltinProbeRuleThresholds(id, thresholds); err != nil { + return err + } + a.riskEngine.ReloadFromStore(a.riskStore) + return nil +} + +// RiskEngineAssess evaluates a statement's risk without executing it. +func (a *App) RiskEngineAssess(datasourceID, statement string) (riskengine.RiskAssessment, error) { + if a.riskEngine == nil { + return riskengine.RiskAssessment{}, errors.New("risk engine not initialized") + } + ds, ok := a.store.Get(datasourceID) + if !ok { + return riskengine.RiskAssessment{}, errors.New("datasource not found") + } + return a.riskEngine.Assess(string(ds.Type), ds.ID, statement), nil +} diff --git a/app_riskengine_test.go b/app_riskengine_test.go new file mode 100644 index 0000000..bae47ab --- /dev/null +++ b/app_riskengine_test.go @@ -0,0 +1,247 @@ +package main + +import ( + "path/filepath" + "testing" + "time" + + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/riskengine" +) + +func newAuthStoreWithPlan(t *testing.T, plan string) *auth.Store { + t.Helper() + store := auth.NewStore(filepath.Join(t.TempDir(), "auth-session.json")) + if err := store.Load(); err != nil { + t.Fatalf("load auth store: %v", err) + } + current := store.Current() + current.Session = &auth.Session{ + AccessToken: "access", + RefreshToken: "refresh", + User: auth.User{ + ID: "user_1", + Email: "user@example.com", + DisplayName: "User", + }, + License: auth.License{ + Plan: plan, + Status: "active", + }, + } + current.Trial = expiredLocalTrial() + if err := store.Save(current); err != nil { + t.Fatalf("save auth store: %v", err) + } + return store +} + +func activeLocalTrial() *auth.Trial { + now := time.Now() + return &auth.Trial{ + StartedAt: now.Add(-time.Hour).Unix(), + ExpiresAt: now.Add(30 * 24 * time.Hour).Unix(), + } +} + +func expiredLocalTrial() *auth.Trial { + now := time.Now() + return &auth.Trial{ + StartedAt: now.Add(-31 * 24 * time.Hour).Unix(), + ExpiresAt: now.Add(-24 * time.Hour).Unix(), + } +} + +func newRiskRuleStore(t *testing.T) *riskengine.Store { + t.Helper() + store := riskengine.NewStore(filepath.Join(t.TempDir(), "risk-rules.json")) + if err := store.Load(); err != nil { + t.Fatalf("load risk rule store: %v", err) + } + return store +} + +func int64Ptr(v int64) *int64 { + return &v +} + +func TestRiskEngineAddRule_FreePlanAllowsCustomRuleManagement(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "free"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineAddRule(riskengine.Rule{ID: "user-rule-1", Enabled: true}) + if err != nil { + t.Fatalf("expected free plan add rule to be allowed, got %v", err) + } +} + +func TestRiskEngineUpdateRule_FreePlanAllowsCustomRuleManagement(t *testing.T) { + riskStore := newRiskRuleStore(t) + if err := riskStore.Create(riskengine.Rule{ID: "user-rule-1", Enabled: true}); err != nil { + t.Fatalf("seed user rule: %v", err) + } + app := &App{ + authStore: newAuthStoreWithPlan(t, "free"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineUpdateRule("user-rule-1", riskengine.Rule{ID: "user-rule-1", Enabled: true}) + if err != nil { + t.Fatalf("expected free plan update rule to be allowed, got %v", err) + } +} + +func TestRiskEngineDeleteRule_FreePlanStillAllowsRemovingExistingRule(t *testing.T) { + riskStore := newRiskRuleStore(t) + if err := riskStore.Create(riskengine.Rule{ID: "user-rule-1", Enabled: true}); err != nil { + t.Fatalf("seed user rule: %v", err) + } + app := &App{ + authStore: newAuthStoreWithPlan(t, "free"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + if err := app.RiskEngineDeleteRule("user-rule-1"); err != nil { + t.Fatalf("expected delete to stay allowed for free, got %v", err) + } +} + +func TestRiskEngineSetEnabled_FreePlanAllowsCustomRuleManagement(t *testing.T) { + riskStore := newRiskRuleStore(t) + if err := riskStore.Create(riskengine.Rule{ID: "user-rule-1", Enabled: true}); err != nil { + t.Fatalf("seed user rule: %v", err) + } + app := &App{ + authStore: newAuthStoreWithPlan(t, "free"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineSetEnabled("user-rule-1", false) + if err != nil { + t.Fatalf("expected free plan enable toggle to be allowed, got %v", err) + } + + rule, ok := riskStore.Get("user-rule-1") + if !ok { + t.Fatal("expected seeded rule to remain present") + } + if rule.Enabled { + t.Fatal("expected free plan toggle to disable the rule") + } +} + +func TestRiskEngineSetBuiltinEnabled_ProPlanCanToggleBuiltinRule(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "pro"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + if err := app.RiskEngineSetBuiltinEnabled("sql-allow-insert", true); err != nil { + t.Fatalf("expected builtin toggle to succeed, got %v", err) + } + + allRules := app.RiskEngineListRules() + for _, rule := range allRules { + if rule.ID != "sql-allow-insert" { + continue + } + if !rule.Enabled { + t.Fatal("expected builtin rule to be enabled after toggle") + } + return + } + + t.Fatal("expected builtin rule to remain listed") +} + +func TestRiskEngineSetBuiltinEnabled_BuiltinToggleDoesNotEnterCustomRuleList(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "pro"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + if err := app.RiskEngineSetBuiltinEnabled("sql-allow-insert", true); err != nil { + t.Fatalf("expected builtin toggle to succeed, got %v", err) + } + + if got := app.RiskEngineListUserRules(); len(got) != 0 { + t.Fatalf("expected builtin toggle to stay out of custom rule list, got %d entries", len(got)) + } +} + +func TestRiskEngineUpdateBuiltinProbeRuleThresholds_ProPlanCanPersistProbeOverrides(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "pro"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineUpdateBuiltinProbeRuleThresholds("probe-wide-scan", riskengine.RuleThresholds{ + MaxExaminedRows: int64Ptr(250), + }) + if err != nil { + t.Fatalf("expected probe threshold update to succeed, got %v", err) + } + + for _, rule := range app.RiskEngineListRules() { + if rule.ID != "probe-wide-scan" { + continue + } + if rule.Thresholds.MaxExaminedRows == nil || *rule.Thresholds.MaxExaminedRows != 250 { + t.Fatalf("MaxExaminedRows = %#v, want 250", rule.Thresholds.MaxExaminedRows) + } + return + } + + t.Fatal("expected probe rule to remain listed") +} + +func TestRiskEngineUpdateBuiltinProbeRuleThresholds_FreePlanBlocked(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "free"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineUpdateBuiltinProbeRuleThresholds("probe-wide-scan", riskengine.RuleThresholds{ + MaxExaminedRows: int64Ptr(250), + }) + if err == nil { + t.Fatal("expected free plan probe threshold update to be blocked") + } + if got := err.Error(); got != "plan_limit_exceeded:risk_rules:free:0" { + t.Fatalf("expected stable risk rule limit error, got %q", got) + } +} + +func TestRiskEngineAddRule_RejectsBuiltinRuleID(t *testing.T) { + riskStore := newRiskRuleStore(t) + app := &App{ + authStore: newAuthStoreWithPlan(t, "pro"), + riskStore: riskStore, + riskEngine: riskengine.NewEngine(), + } + + err := app.RiskEngineAddRule(riskengine.Rule{ + ID: "sql-allow-insert", + Description: "custom collision", + Enabled: false, + Action: riskengine.ActionWarn, + }) + if err == nil { + t.Fatal("expected builtin rule ID to be rejected for custom rules") + } +} diff --git a/app_schema_knowledge.go b/app_schema_knowledge.go new file mode 100644 index 0000000..435a8a0 --- /dev/null +++ b/app_schema_knowledge.go @@ -0,0 +1,532 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/schemaprivacy" +) + +const ( + schemaKnowledgeMaxPromptEntities = 80 + schemaKnowledgeMaxPromptColumns = 20 +) + +type schemaKnowledgeEntity struct { + Name string `json:"name"` + Columns []console.ColumnInfo `json:"columns,omitempty"` + Indexes []console.IndexInfo `json:"indexes,omitempty"` + Details []console.DetailItem `json:"details,omitempty"` +} + +type schemaKnowledgeSnapshot struct { + DatasourceID string `json:"datasourceId"` + DatasourceName string `json:"datasourceName"` + DatasourceType string `json:"datasourceType"` + Database string `json:"database,omitempty"` + CacheKey string `json:"cacheKey"` + UpdatedAt int64 `json:"updatedAt"` + SchemaHash string `json:"schemaHash"` + Entities []schemaKnowledgeEntity `json:"entities"` +} + +type schemaKnowledgeERDocument struct { + DatasourceID string `json:"datasourceId"` + DatasourceName string `json:"datasourceName"` + DatasourceType string `json:"datasourceType"` + SchemaHash string `json:"schemaHash"` + GeneratedAt int64 `json:"generatedAt"` + Content string `json:"content"` +} + +type schemaKnowledgeManager struct { + root string + models aichat.ModelResolver + schemaPrivacy *schemaprivacy.AuditStore + providerInfo providerSummaryFunc + consentLookup func(string) (datasource.DataSource, bool) + + mu sync.Mutex + running map[string]bool +} + +func newSchemaKnowledgeManager(root string, models aichat.ModelResolver) *schemaKnowledgeManager { + trimmed := strings.TrimSpace(root) + if trimmed == "" { + return nil + } + return &schemaKnowledgeManager{ + root: trimmed, + models: models, + running: make(map[string]bool), + } +} + +// SetSchemaPrivacy injects the consent audit + provider lookup. Callers (the +// App constructor) wire this after both stores are built so schemaKB can +// refuse to send the snapshot to a model when the user hasn't granted egress. +func (m *schemaKnowledgeManager) SetSchemaPrivacy(audit *schemaprivacy.AuditStore, providerInfo providerSummaryFunc) { + if m == nil { + return + } + m.schemaPrivacy = audit + m.providerInfo = providerInfo +} + +// SetDatasourceLookup wires a fresh-snapshot lookup so maybeGenerateER can +// re-read consent state right before the gate decision. Async ER work can sit +// in the queue for seconds while the user flips the toggle; without this the +// gate would evaluate the stale option captured at queue time and ship schema +// in defiance of the revocation. The chat tools handle the same race the same +// way inside schemaPrivacyGate. +func (m *schemaKnowledgeManager) SetDatasourceLookup(lookup func(string) (datasource.DataSource, bool)) { + if m == nil { + return + } + m.consentLookup = lookup +} + +func (m *schemaKnowledgeManager) TryBegin(cacheKey string) bool { + if m == nil { + return false + } + key := strings.TrimSpace(cacheKey) + if key == "" { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + if m.running[key] { + return false + } + m.running[key] = true + return true +} + +func (m *schemaKnowledgeManager) End(cacheKey string) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.running, strings.TrimSpace(cacheKey)) +} + +func (m *schemaKnowledgeManager) SyncFromCache(ctx context.Context, ds datasource.DataSource, cacheKey string, entry console.EntitySchemaCacheEntry) error { + if m == nil { + return nil + } + snapshot := buildSchemaKnowledgeSnapshot(ds, cacheKey, entry) + if len(snapshot.Entities) == 0 { + return nil + } + dir := m.datasourceKnowledgeDir(ds) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := writeJSONFile(filepath.Join(dir, "schema.json"), snapshot); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "schema.md"), []byte(renderSchemaKnowledgeMarkdown(snapshot)), 0o644); err != nil { + return err + } + if err := m.maybeGenerateER(ctx, ds, dir, snapshot); err != nil { + return err + } + return nil +} + +func (m *schemaKnowledgeManager) GetSchemaKnowledge(ds datasource.DataSource, entityPattern string) (map[string]any, error) { + if m == nil { + return nil, errors.New("schema knowledge is not available") + } + snapshot, err := m.readSnapshot(ds) + if err != nil { + return nil, err + } + filtered := snapshot.Entities + needle := strings.ToLower(strings.TrimSpace(entityPattern)) + if needle != "" { + filtered = make([]schemaKnowledgeEntity, 0, len(snapshot.Entities)) + for _, item := range snapshot.Entities { + name := strings.ToLower(strings.TrimSpace(item.Name)) + if strings.Contains(name, needle) { + filtered = append(filtered, item) + } + } + } + return map[string]any{ + "datasourceId": snapshot.DatasourceID, + "datasourceName": snapshot.DatasourceName, + "datasourceType": snapshot.DatasourceType, + "database": snapshot.Database, + "cacheKey": snapshot.CacheKey, + "updatedAt": snapshot.UpdatedAt, + "schemaHash": snapshot.SchemaHash, + "entityCount": len(filtered), + "entities": filtered, + }, nil +} + +func (m *schemaKnowledgeManager) GetERKnowledge(ds datasource.DataSource) (map[string]any, error) { + if m == nil { + return nil, errors.New("schema knowledge is not available") + } + doc, err := m.readER(ds) + if err != nil { + return nil, err + } + return map[string]any{ + "datasourceId": doc.DatasourceID, + "datasourceName": doc.DatasourceName, + "datasourceType": doc.DatasourceType, + "schemaHash": doc.SchemaHash, + "generatedAt": doc.GeneratedAt, + "content": doc.Content, + }, nil +} + +func (m *schemaKnowledgeManager) maybeGenerateER(ctx context.Context, ds datasource.DataSource, dir string, snapshot schemaKnowledgeSnapshot) error { + if m == nil || m.models == nil { + return nil + } + existing, err := readERDoc(filepath.Join(dir, "er.json")) + if err == nil && strings.TrimSpace(existing.SchemaHash) == strings.TrimSpace(snapshot.SchemaHash) && strings.TrimSpace(existing.Content) != "" { + return nil + } + // Re-read the datasource right before the consent decision: this path + // is reached from syncSchemaKnowledgeAsync, which captured `ds` when it + // queued the work. Auto-describe and cache rebuild can take long enough + // for the user to flip the consent toggle in the meantime, and we have + // to honor that revocation at send time — not at queue time. + if m.consentLookup != nil { + if fresh, ok := m.consentLookup(strings.TrimSpace(ds.ID)); ok { + ds = fresh + } + } + // Refuse-and-audit before doing any model work: a denied/unset + // datasource must always produce a denial audit row, regardless of + // whether a model would even be resolvable. We rebuild the summary on + // the allowed branch below, so a refusal-only summary keeps counts + // out of the denied row (it represents an attempted send, not real + // scope) while still capturing provider/model context. + if schemaprivacy.ConsentOf(ds) != schemaprivacy.ConsentAllowed { + denySummary := schemaprivacy.SendSummary{} + if m.providerInfo != nil { + denySummary.ProviderType, denySummary.Model, denySummary.AIConfigID = m.providerInfo("") + } + _ = schemaprivacy.Gate(m.schemaPrivacy, ds, schemaprivacy.TriggerSchemaKnowledgeERGenerate, denySummary) + return nil + } + // Resolve the model before recording an allowed-egress audit row. If + // no model can be resolved (no AI config configured, provider key + // missing) nothing actually leaves the process, and writing an + // allowed row here would mislead later investigations into thinking + // schema metadata had been sent. + model, err := m.models.Resolve("") + if err != nil { + return nil + } + summary := schemaprivacy.SendSummary{ + EntityCount: len(snapshot.Entities), + } + for _, e := range snapshot.Entities { + summary.FieldCount += len(e.Columns) + if len(e.Details) > 0 { + summary.IncludesComments = true + } + } + if m.providerInfo != nil { + summary.ProviderType, summary.Model, summary.AIConfigID = m.providerInfo("") + } + if gateErr := schemaprivacy.Gate(m.schemaPrivacy, ds, schemaprivacy.TriggerSchemaKnowledgeERGenerate, summary); gateErr != nil { + // Race: consent flipped to denied between the pre-check above and + // here. The denial is already recorded by Gate; nothing more to do. + return nil + } + promptPayload, err := json.MarshalIndent(buildERPromptSnapshot(snapshot), "", " ") + if err != nil { + return err + } + systemPrompt := "You are an expert data modeler. Given datasource tables/collections and fields, infer likely entity relationships and return concise markdown. Include a Mermaid ER diagram block when possible." + userPrompt := "Datasource schema snapshot JSON:\n\n" + string(promptPayload) + "\n\nReturn markdown with:\n1) Relationship summary bullets\n2) Mermaid erDiagram block\n3) Notes/assumptions." + + response, err := model.Chat(ctx, systemPrompt, []aichat.Message{{Role: "user", Content: userPrompt}}) + if err != nil { + return nil + } + trimmed := strings.TrimSpace(response) + if trimmed == "" { + return nil + } + doc := schemaKnowledgeERDocument{ + DatasourceID: snapshot.DatasourceID, + DatasourceName: snapshot.DatasourceName, + DatasourceType: snapshot.DatasourceType, + SchemaHash: snapshot.SchemaHash, + GeneratedAt: time.Now().UTC().Unix(), + Content: trimmed, + } + if err := writeJSONFile(filepath.Join(dir, "er.json"), doc); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "er.md"), []byte(trimmed+"\n"), 0o644) +} + +func (m *schemaKnowledgeManager) datasourceKnowledgeDir(ds datasource.DataSource) string { + name := sanitizeKnowledgePathComponent(ds.Name) + if name == "" { + name = sanitizeKnowledgePathComponent(ds.ID) + } + if name == "" { + name = "datasource" + } + id := sanitizeKnowledgePathComponent(ds.ID) + if id == "" { + id = "default" + } + return filepath.Join(m.root, name, id) +} + +func (m *schemaKnowledgeManager) readSnapshot(ds datasource.DataSource) (schemaKnowledgeSnapshot, error) { + path := filepath.Join(m.datasourceKnowledgeDir(ds), "schema.json") + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return schemaKnowledgeSnapshot{}, errors.New("schema knowledge not found") + } + return schemaKnowledgeSnapshot{}, err + } + var out schemaKnowledgeSnapshot + if err := json.Unmarshal(content, &out); err != nil { + return schemaKnowledgeSnapshot{}, err + } + return out, nil +} + +func (m *schemaKnowledgeManager) readER(ds datasource.DataSource) (schemaKnowledgeERDocument, error) { + path := filepath.Join(m.datasourceKnowledgeDir(ds), "er.json") + return readERDoc(path) +} + +func buildSchemaKnowledgeSnapshot(ds datasource.DataSource, cacheKey string, entry console.EntitySchemaCacheEntry) schemaKnowledgeSnapshot { + namesSet := make(map[string]struct{}, len(entry.Entities)+len(entry.Details)) + for _, name := range entry.Entities { + trimmed := strings.TrimSpace(name) + if trimmed != "" { + namesSet[trimmed] = struct{}{} + } + } + for name := range entry.Details { + trimmed := strings.TrimSpace(name) + if trimmed != "" { + namesSet[trimmed] = struct{}{} + } + } + names := make([]string, 0, len(namesSet)) + for name := range namesSet { + names = append(names, name) + } + sort.Strings(names) + + entities := make([]schemaKnowledgeEntity, 0, len(names)) + for _, name := range names { + item := schemaKnowledgeEntity{Name: name} + if detail, ok := entry.Details[name]; ok { + item.Columns = append([]console.ColumnInfo(nil), detail.Columns...) + item.Indexes = append([]console.IndexInfo(nil), detail.Indexes...) + item.Details = append([]console.DetailItem(nil), detail.Details...) + } + entities = append(entities, item) + } + + updatedAt := entry.UpdatedAt + if updatedAt <= 0 { + updatedAt = time.Now().UTC().Unix() + } + snapshot := schemaKnowledgeSnapshot{ + DatasourceID: strings.TrimSpace(ds.ID), + DatasourceName: strings.TrimSpace(ds.Name), + DatasourceType: strings.TrimSpace(string(ds.Type)), + Database: strings.TrimSpace(ds.Database), + CacheKey: strings.TrimSpace(cacheKey), + UpdatedAt: updatedAt, + Entities: entities, + } + snapshot.SchemaHash = schemaKnowledgeHash(snapshot) + return snapshot +} + +func schemaKnowledgeHash(snapshot schemaKnowledgeSnapshot) string { + type hashPayload struct { + DatasourceID string `json:"datasourceId"` + DatasourceType string `json:"datasourceType"` + Database string `json:"database,omitempty"` + Entities []schemaKnowledgeEntity `json:"entities"` + } + payload, err := json.Marshal(hashPayload{ + DatasourceID: snapshot.DatasourceID, + DatasourceType: snapshot.DatasourceType, + Database: snapshot.Database, + Entities: snapshot.Entities, + }) + if err != nil { + return "" + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:]) +} + +func renderSchemaKnowledgeMarkdown(snapshot schemaKnowledgeSnapshot) string { + var b strings.Builder + b.WriteString("# Datasource Schema Snapshot\n\n") + b.WriteString(fmt.Sprintf("- Datasource: %s (`%s`)\n", sanitizeInline(snapshot.DatasourceName), sanitizeInline(snapshot.DatasourceID))) + b.WriteString(fmt.Sprintf("- Type: `%s`\n", sanitizeInline(snapshot.DatasourceType))) + if strings.TrimSpace(snapshot.Database) != "" { + b.WriteString(fmt.Sprintf("- Database: `%s`\n", sanitizeInline(snapshot.Database))) + } + b.WriteString(fmt.Sprintf("- Cache Key: `%s`\n", sanitizeInline(snapshot.CacheKey))) + b.WriteString(fmt.Sprintf("- Updated At: %s\n", time.Unix(snapshot.UpdatedAt, 0).UTC().Format(time.RFC3339))) + b.WriteString(fmt.Sprintf("- Schema Hash: `%s`\n", sanitizeInline(snapshot.SchemaHash))) + b.WriteString("\n") + b.WriteString(fmt.Sprintf("## Entities (%d)\n\n", len(snapshot.Entities))) + + for _, entity := range snapshot.Entities { + b.WriteString(fmt.Sprintf("### `%s`\n\n", sanitizeInline(entity.Name))) + if len(entity.Columns) > 0 { + b.WriteString("Columns:\n") + for _, col := range entity.Columns { + nullable := strings.TrimSpace(col.Nullable) + if nullable == "" { + nullable = "-" + } + b.WriteString(fmt.Sprintf("- `%s` (%s, nullable=%s)\n", sanitizeInline(col.Name), sanitizeInline(col.DataType), sanitizeInline(nullable))) + } + } + if len(entity.Indexes) > 0 { + b.WriteString("Indexes:\n") + for _, idx := range entity.Indexes { + kind := "index" + if idx.Unique { + kind = "unique" + } + b.WriteString(fmt.Sprintf("- `%s` [%s] column=%s\n", sanitizeInline(idx.Name), kind, sanitizeInline(idx.Column))) + } + } + if len(entity.Details) > 0 { + b.WriteString("Details:\n") + for _, detail := range entity.Details { + value := strings.TrimSpace(fmt.Sprint(detail.Value)) + if value == "" { + value = "-" + } + b.WriteString(fmt.Sprintf("- %s: %s\n", sanitizeInline(detail.Label), sanitizeInline(value))) + } + } + if len(entity.Columns) == 0 && len(entity.Indexes) == 0 && len(entity.Details) == 0 { + b.WriteString("- No detail snapshot available yet.\n") + } + b.WriteString("\n") + } + return b.String() +} + +func buildERPromptSnapshot(snapshot schemaKnowledgeSnapshot) schemaKnowledgeSnapshot { + trimmed := snapshot + if len(trimmed.Entities) <= schemaKnowledgeMaxPromptEntities { + for i := range trimmed.Entities { + if len(trimmed.Entities[i].Columns) > schemaKnowledgeMaxPromptColumns { + trimmed.Entities[i].Columns = append([]console.ColumnInfo(nil), trimmed.Entities[i].Columns[:schemaKnowledgeMaxPromptColumns]...) + } + } + return trimmed + } + trimmed.Entities = append([]schemaKnowledgeEntity(nil), snapshot.Entities[:schemaKnowledgeMaxPromptEntities]...) + for i := range trimmed.Entities { + if len(trimmed.Entities[i].Columns) > schemaKnowledgeMaxPromptColumns { + trimmed.Entities[i].Columns = append([]console.ColumnInfo(nil), trimmed.Entities[i].Columns[:schemaKnowledgeMaxPromptColumns]...) + } + } + return trimmed +} + +func writeJSONFile(path string, value any) error { + payload, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, payload, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func readERDoc(path string) (schemaKnowledgeERDocument, error) { + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return schemaKnowledgeERDocument{}, errors.New("ER knowledge not found") + } + return schemaKnowledgeERDocument{}, err + } + var out schemaKnowledgeERDocument + if err := json.Unmarshal(content, &out); err != nil { + return schemaKnowledgeERDocument{}, err + } + if strings.TrimSpace(out.Content) == "" { + return schemaKnowledgeERDocument{}, errors.New("ER knowledge is empty") + } + return out, nil +} + +func sanitizeKnowledgePathComponent(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + out := strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= 'A' && r <= 'Z': + return r + case r >= '0' && r <= '9': + return r + case r == '-' || r == '_' || r == '.': + return r + case r == ' ' || r == '/': + return '_' + default: + return '_' + } + }, trimmed) + out = strings.Trim(out, "._") + return out +} + +func sanitizeInline(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + trimmed = strings.ReplaceAll(trimmed, "`", "") + trimmed = strings.ReplaceAll(trimmed, "\n", " ") + return strings.TrimSpace(trimmed) +} diff --git a/app_schema_knowledge_test.go b/app_schema_knowledge_test.go new file mode 100644 index 0000000..9d89375 --- /dev/null +++ b/app_schema_knowledge_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/schemaprivacy" +) + +type schemaKnowledgeTestModel struct { + response string +} + +func (m schemaKnowledgeTestModel) Chat(ctx context.Context, systemPrompt string, messages []aichat.Message) (string, error) { + _ = ctx + _ = systemPrompt + _ = messages + return m.response, nil +} + +type schemaKnowledgeTestResolver struct { + model aichat.Model +} + +func (r schemaKnowledgeTestResolver) Resolve(aiConfigID string) (aichat.Model, error) { + _ = aiConfigID + return r.model, nil +} + +func TestSchemaKnowledgeManager_SyncAndReadSchema(t *testing.T) { + root := t.TempDir() + manager := newSchemaKnowledgeManager(root, nil) + ds := datasource.DataSource{ID: "ds_mysql", Name: "Mysql", Type: datasource.TypeMySQL} + entry := console.EntitySchemaCacheEntry{ + UpdatedAt: 1772000000, + Entities: []string{"orders"}, + Details: map[string]console.DescribeResult{ + "orders": { + Columns: []console.ColumnInfo{{Name: "id", DataType: "int"}}, + Indexes: []console.IndexInfo{{Name: "PRIMARY", Column: "id", Unique: true}}, + }, + }, + } + + if err := manager.SyncFromCache(context.Background(), ds, "ds_mysql", entry); err != nil { + t.Fatalf("SyncFromCache: %v", err) + } + + schema, err := manager.GetSchemaKnowledge(ds, "orders") + if err != nil { + t.Fatalf("GetSchemaKnowledge: %v", err) + } + entities, _ := schema["entities"].([]schemaKnowledgeEntity) + if len(entities) != 1 || entities[0].Name != "orders" { + t.Fatalf("expected orders entity in schema knowledge, got %#v", schema["entities"]) + } + + if _, err := manager.GetERKnowledge(ds); err == nil { + t.Fatalf("expected missing ER knowledge error when AI provider is not configured") + } +} + +func TestSchemaKnowledgeManager_GeneratesERWhenModelConfigured(t *testing.T) { + root := t.TempDir() + resolver := schemaKnowledgeTestResolver{model: schemaKnowledgeTestModel{response: "# ER\n\norders ||--o{ order_items : contains"}} + manager := newSchemaKnowledgeManager(root, resolver) + manager.SetSchemaPrivacy(nil, nil) + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "Mysql", + Type: datasource.TypeMySQL, + Options: map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentAllowed), + }, + } + entry := console.EntitySchemaCacheEntry{ + UpdatedAt: 1772000000, + Entities: []string{"orders", "order_items"}, + } + + if err := manager.SyncFromCache(context.Background(), ds, "ds_mysql", entry); err != nil { + t.Fatalf("SyncFromCache: %v", err) + } + + er, err := manager.GetERKnowledge(ds) + if err != nil { + t.Fatalf("GetERKnowledge: %v", err) + } + content := er["content"] + if content == nil || content == "" { + t.Fatalf("expected ER content, got %#v", er) + } +} + +// failingResolver mimics the real-world case where the user has consented +// to schema egress but no AI provider has been configured (or the +// configured one is broken). Resolve must error and the ER manager must +// react by skipping the chat call entirely. +type failingResolver struct{} + +func (failingResolver) Resolve(string) (aichat.Model, error) { + return nil, errors.New("no usable AI config") +} + +// TestSchemaKnowledgeManager_NoAuditWhenModelUnresolvable guards against +// codex P2 r3165...: maybeGenerateER used to call schemaprivacy.Gate +// (which writes an "allowed" audit row) before resolving the model. When +// Resolve fails no schema actually goes out, so the audit row would lie +// about an egress that never happened. The fix moves Resolve ahead of +// Gate so the allowed row is only written on the path that genuinely +// reaches a model. +func TestSchemaKnowledgeManager_NoAuditWhenModelUnresolvable(t *testing.T) { + root := t.TempDir() + manager := newSchemaKnowledgeManager(root, failingResolver{}) + auditPath := filepath.Join(t.TempDir(), "schema-llm-audit.jsonl") + audit := schemaprivacy.NewAuditStore(auditPath) + manager.SetSchemaPrivacy(audit, nil) + + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "Mysql", + Type: datasource.TypeMySQL, + Options: map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentAllowed), + }, + } + entry := console.EntitySchemaCacheEntry{ + UpdatedAt: 1772000000, + Entities: []string{"orders"}, + } + + if err := manager.SyncFromCache(context.Background(), ds, "ds_mysql", entry); err != nil { + t.Fatalf("SyncFromCache: %v", err) + } + + items, err := audit.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected no audit rows when no model resolves; got %d: %#v", len(items), items) + } +} + +// TestSchemaKnowledgeManager_HonorsRevokedConsentAtSendTime guards against +// codex P1 r3171...: maybeGenerateER used to gate against the `ds` snapshot +// captured when syncSchemaKnowledgeAsync queued the work. Auto-describe and +// cache rebuild can take long enough for the user to flip the toggle from +// allowed to denied; without re-reading consent at gate time, the denial is +// ignored and schema metadata leaves the box. The fix wires a consentLookup +// the manager calls right before the consent decision. +func TestSchemaKnowledgeManager_HonorsRevokedConsentAtSendTime(t *testing.T) { + root := t.TempDir() + resolver := schemaKnowledgeTestResolver{model: schemaKnowledgeTestModel{response: "# ER"}} + manager := newSchemaKnowledgeManager(root, resolver) + auditPath := filepath.Join(t.TempDir(), "schema-llm-audit.jsonl") + audit := schemaprivacy.NewAuditStore(auditPath) + manager.SetSchemaPrivacy(audit, nil) + + // The caller still holds the stale "allowed" snapshot it captured before + // the slow fetch began. + staleDS := datasource.DataSource{ + ID: "ds_mysql", + Name: "Mysql", + Type: datasource.TypeMySQL, + Options: map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentAllowed), + }, + } + // The store reflects the user's revocation that landed mid-fetch. + freshDS := staleDS + freshDS.Options = map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentDenied), + } + manager.SetDatasourceLookup(func(id string) (datasource.DataSource, bool) { + if id == staleDS.ID { + return freshDS, true + } + return datasource.DataSource{}, false + }) + + entry := console.EntitySchemaCacheEntry{ + UpdatedAt: 1772000000, + Entities: []string{"orders"}, + } + if err := manager.SyncFromCache(context.Background(), staleDS, "ds_mysql", entry); err != nil { + t.Fatalf("SyncFromCache: %v", err) + } + + items, err := audit.List(schemaprivacy.AuditFilter{DatasourceID: staleDS.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 1 || items[0].Status != schemaprivacy.StatusDenied { + t.Fatalf("expected exactly one denied audit row when consent revoked mid-fetch, got %d: %#v", len(items), items) + } +} + +// TestSchemaKnowledgeManager_AuditsDeniedEvenWithoutModel covers the +// converse: a denied datasource must still produce a denial audit row, +// regardless of whether a model can be resolved. The denial is the +// security event we cannot drop. +func TestSchemaKnowledgeManager_AuditsDeniedEvenWithoutModel(t *testing.T) { + root := t.TempDir() + manager := newSchemaKnowledgeManager(root, failingResolver{}) + auditPath := filepath.Join(t.TempDir(), "schema-llm-audit.jsonl") + audit := schemaprivacy.NewAuditStore(auditPath) + manager.SetSchemaPrivacy(audit, nil) + + ds := datasource.DataSource{ + ID: "ds_mysql", + Name: "Mysql", + Type: datasource.TypeMySQL, + Options: map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentDenied), + }, + } + entry := console.EntitySchemaCacheEntry{ + UpdatedAt: 1772000000, + Entities: []string{"orders"}, + } + + if err := manager.SyncFromCache(context.Background(), ds, "ds_mysql", entry); err != nil { + t.Fatalf("SyncFromCache: %v", err) + } + + items, err := audit.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 1 || items[0].Status != schemaprivacy.StatusDenied { + t.Fatalf("expected exactly one denied audit row, got %d: %#v", len(items), items) + } +} diff --git a/app_schema_privacy.go b/app_schema_privacy.go new file mode 100644 index 0000000..8e1e112 --- /dev/null +++ b/app_schema_privacy.go @@ -0,0 +1,149 @@ +package main + +import ( + "strings" + + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/schemaprivacy" +) + +// SchemaPrivacyListConsents returns one ConsentSummary per registered +// datasource, joined with the most recent egress timestamp from the audit +// log. The frontend renders this as the schema-egress overview tab. +// +// One single pass over the audit jsonl backs the whole list — calling +// LastForDatasource once per row would re-scan the file for every +// datasource, which compounds quickly as the log accumulates entries. +func (a *App) SchemaPrivacyListConsents() map[string]any { + if a.store == nil { + return map[string]any{"error": "datasource store not initialized"} + } + all := a.store.List() + var latest map[string]schemaprivacy.AuditEntry + if a.schemaPrivacy != nil { + if got, err := a.schemaPrivacy.LatestByDatasource(); err == nil { + latest = got + } + } + out := make([]schemaprivacy.ConsentSummary, 0, len(all)) + for _, ds := range all { + summary := schemaprivacy.ConsentSummary{ + DatasourceID: ds.ID, + DatasourceName: ds.Name, + DatasourceType: string(ds.Type), + Consent: string(schemaprivacy.ConsentOf(ds)), + } + if entry, ok := latest[ds.ID]; ok { + summary.LastSentAt = entry.CreatedAt + summary.LastStatus = string(entry.Status) + } + out = append(out, summary) + } + return map[string]any{"items": out} +} + +// SchemaPrivacyGetConsent returns the consent for a single datasource. Used +// by the AI Chat sidebar to render an inline "schema egress is off" banner +// without listing every datasource. +func (a *App) SchemaPrivacyGetConsent(datasourceID string) map[string]any { + id := strings.TrimSpace(datasourceID) + if id == "" { + return map[string]any{"error": "datasource ID is required"} + } + if a.store == nil { + return map[string]any{"error": "datasource store not initialized"} + } + ds, ok := a.store.Get(id) + if !ok { + return map[string]any{"error": "datasource not found"} + } + resp := map[string]any{ + "datasourceId": ds.ID, + "datasourceName": ds.Name, + "datasourceType": string(ds.Type), + "consent": string(schemaprivacy.ConsentOf(ds)), + } + if a.schemaPrivacy != nil { + if entry, ok, err := a.schemaPrivacy.LastForDatasource(ds.ID); err == nil && ok { + resp["lastSentAt"] = entry.CreatedAt + resp["lastStatus"] = string(entry.Status) + } + } + return resp +} + +// SchemaPrivacySetConsent persists the user's choice for a datasource. The +// only valid inputs are "", "allowed", "denied" — anything else is +// normalized to "" (unset). We round-trip through the datasource Update path +// so existing snapshot/audit hooks fire and the in-memory store stays +// consistent with the on-disk JSON. +func (a *App) SchemaPrivacySetConsent(datasourceID, consent string) map[string]any { + id := strings.TrimSpace(datasourceID) + if id == "" { + return map[string]any{"error": "datasource ID is required"} + } + if a.store == nil { + return map[string]any{"error": "datasource store not initialized"} + } + ds, ok := a.store.Get(id) + if !ok { + return map[string]any{"error": "datasource not found"} + } + normalized := schemaprivacy.NormalizeConsent(consent) + opts := cloneOptionsMap(ds.Options) + opts, _ = schemaprivacy.ApplyConsent(opts, normalized) + updated := datasource.DataSource{ + ID: ds.ID, + Name: ds.Name, + Type: ds.Type, + Host: ds.Host, + Port: ds.Port, + Username: ds.Username, + Password: ds.Password, + Database: ds.Database, + AuthSource: ds.AuthSource, + Options: opts, + } + if _, err := a.store.Update(ds.ID, updated); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{ + "datasourceId": ds.ID, + "consent": string(normalized), + } +} + +// SchemaPrivacyListAudit returns recent egress entries, optionally filtered +// to a single datasource. Limit defaults to 100 when zero or negative; the +// frontend caps its UI at the first page anyway. +func (a *App) SchemaPrivacyListAudit(datasourceID string, limit int) map[string]any { + if a.schemaPrivacy == nil { + return map[string]any{"items": []schemaprivacy.AuditEntry{}} + } + if limit <= 0 || limit > 500 { + limit = 100 + } + items, err := a.schemaPrivacy.List(schemaprivacy.AuditFilter{ + DatasourceID: strings.TrimSpace(datasourceID), + Limit: limit, + }) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"items": items} +} + +// cloneOptionsMap copies a datasource Options map so callers can mutate the +// returned map without aliasing the in-memory store. Existing keys are +// preserved verbatim — this includes trust level, dangerous-statement +// toggles, and per-adapter knobs we don't know about. +func cloneOptionsMap(in map[string]any) map[string]any { + if in == nil { + return map[string]any{} + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/app_schema_privacy_test.go b/app_schema_privacy_test.go new file mode 100644 index 0000000..a3ae955 --- /dev/null +++ b/app_schema_privacy_test.go @@ -0,0 +1,442 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/schemaprivacy" + "futrixdata/platform/internal/sensitivity" +) + +func newSchemaPrivacyTestApp(t *testing.T) (*App, datasource.DataSource) { + t.Helper() + root := t.TempDir() + store := datasource.NewStore(filepath.Join(root, "datasources.json")) + created, err := store.Create(datasource.DataSource{ + Name: "Schema Egress Test", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create datasource: %v", err) + } + audit := schemaprivacy.NewAuditStore(filepath.Join(root, "schema-llm-audit.jsonl")) + app := &App{store: store, schemaPrivacy: audit} + return app, created +} + +func TestSchemaPrivacy_ConsentRoundtrip(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + + // Default consent must be unset — that's the safe default the task is + // built around. If this regresses to something else, the gate would + // silently start letting metadata out. + got := app.SchemaPrivacyGetConsent(ds.ID) + if c, _ := got["consent"].(string); c != "" { + t.Fatalf("expected default consent unset, got %q", c) + } + + resp := app.SchemaPrivacySetConsent(ds.ID, "allowed") + if c, _ := resp["consent"].(string); c != "allowed" { + t.Fatalf("expected allowed after set, got %q (resp=%#v)", c, resp) + } + + // Round-trip through the store: SetConsent must persist via Update so a + // fresh Get reflects the change. This is what guarantees the gate sees + // the new value on the next call. + got2 := app.SchemaPrivacyGetConsent(ds.ID) + if c, _ := got2["consent"].(string); c != "allowed" { + t.Fatalf("expected persisted allowed, got %q", c) + } + + // Junk values normalize to empty so a malformed write from anywhere + // can't escalate to "allowed" by accident. + resp = app.SchemaPrivacySetConsent(ds.ID, "yolo") + if c, _ := resp["consent"].(string); c != "" { + t.Fatalf("expected normalized empty for junk consent, got %q", c) + } +} + +func TestSchemaPrivacy_ListConsentsCoversAllDatasources(t *testing.T) { + app, ds1 := newSchemaPrivacyTestApp(t) + ds2, err := app.store.Create(datasource.DataSource{ + Name: "Second", + Type: datasource.TypeMySQL, + Host: "127.0.0.1", + Port: 3306, + }) + if err != nil { + t.Fatalf("create second datasource: %v", err) + } + + app.SchemaPrivacySetConsent(ds1.ID, "allowed") + app.SchemaPrivacySetConsent(ds2.ID, "denied") + + resp := app.SchemaPrivacyListConsents() + items, ok := resp["items"].([]schemaprivacy.ConsentSummary) + if !ok { + t.Fatalf("expected []ConsentSummary, got %T", resp["items"]) + } + if len(items) != 2 { + t.Fatalf("expected 2 summary items, got %d", len(items)) + } + + consentByID := map[string]string{} + for _, item := range items { + consentByID[item.DatasourceID] = item.Consent + } + if consentByID[ds1.ID] != "allowed" { + t.Fatalf("expected ds1 consent=allowed, got %q", consentByID[ds1.ID]) + } + if consentByID[ds2.ID] != "denied" { + t.Fatalf("expected ds2 consent=denied, got %q", consentByID[ds2.ID]) + } +} + +func TestSchemaPrivacy_ListAuditFiltersByDatasource(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + + // Append two entries: one for ds, one for an unrelated id. The list + // view filtered by ds.ID must drop the unrelated one — otherwise the + // per-datasource audit panel would leak rows from other datasources. + if err := app.schemaPrivacy.Append(schemaprivacy.AuditEntry{ + DatasourceID: ds.ID, + Status: schemaprivacy.StatusAllowed, + }); err != nil { + t.Fatalf("append target: %v", err) + } + if err := app.schemaPrivacy.Append(schemaprivacy.AuditEntry{ + DatasourceID: "other", + Status: schemaprivacy.StatusDenied, + }); err != nil { + t.Fatalf("append other: %v", err) + } + + resp := app.SchemaPrivacyListAudit(ds.ID, 50) + items, ok := resp["items"].([]schemaprivacy.AuditEntry) + if !ok { + t.Fatalf("expected []AuditEntry, got %T", resp["items"]) + } + if len(items) != 1 { + t.Fatalf("expected 1 filtered entry, got %d (%#v)", len(items), items) + } + if items[0].DatasourceID != ds.ID { + t.Fatalf("expected filtered to ds.ID, got %q", items[0].DatasourceID) + } +} + +func TestSchemaPrivacy_SetConsentRejectsUnknownDatasource(t *testing.T) { + app, _ := newSchemaPrivacyTestApp(t) + resp := app.SchemaPrivacySetConsent("does-not-exist", "allowed") + if _, ok := resp["error"]; !ok { + t.Fatalf("expected error for missing datasource, got %#v", resp) + } +} + +// TestSchemaPrivacy_AuditRecordsAIConfigIDFromContext exercises the full chat +// tool gate path to make sure the audit reflects the AI config the *current +// turn* is using, not whichever config the resolver picks for an empty +// lookup. A previous version of schemaPrivacyGate always asked for "" and +// got the preferred config back, which made the "where did this go?" +// audit field unreliable when the user picked a non-default config. +func TestSchemaPrivacy_AuditRecordsAIConfigIDFromContext(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + app.SchemaPrivacySetConsent(ds.ID, "allowed") + + manager := console.NewManager() + manager.Register(ds.Type, appExecuteAdapterStub{}) + + // Provider lookup: the override path returns turn-specific values; the + // fallback path returns the preferred values. The test asserts we hit + // the override path with the id stamped on the chat ctx. + provider := func(id string) (string, string, string) { + if id == "turn-cfg" { + return "anthropic", "claude-opus-4", "turn-cfg" + } + return "openai", "gpt-default", "preferred-cfg" + } + + tools := newAppAIChatTools(app.store, manager, nil, nil, nil, nil, app.schemaPrivacy, provider, nil).(*appAIChatTools) + + ctx := schemaprivacy.ContextWithAIConfigID(context.Background(), "turn-cfg") + if _, err := tools.ListEntities(ctx, ds.ID, "", ""); err != nil { + t.Fatalf("ListEntities: %v", err) + } + + items, err := app.schemaPrivacy.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 audit entry, got %d", len(items)) + } + got := items[0] + if got.AIConfigID != "turn-cfg" || got.ProviderType != "anthropic" || got.Model != "claude-opus-4" { + t.Fatalf("expected audit to capture turn-specific provider, got %#v", got) + } + if got.Status != schemaprivacy.StatusAllowed { + t.Fatalf("expected allowed status, got %q", got.Status) + } +} + +// blockingDescribeAdapter mimics a slow DescribeEntity so a test can run +// real-world timing logic: the goroutine inside SensitivityScan calls +// DescribeEntity, blocks here, the test toggles consent, then releases. +type blockingDescribeAdapter struct { + appExecuteAdapterStub + release chan struct{} + describes chan string +} + +func (b *blockingDescribeAdapter) ListEntities(context.Context, datasource.DataSource, console.ListOptions) ([]string, error) { + return []string{"users"}, nil +} + +func (b *blockingDescribeAdapter) DescribeEntity(_ context.Context, _ datasource.DataSource, name string) (console.DescribeResult, error) { + select { + case b.describes <- name: + default: + } + <-b.release + _ = name + return console.DescribeResult{ + Columns: []console.ColumnInfo{{Name: "id", DataType: "int"}}, + }, nil +} + +// TestSensitivityScan_RevocationDuringDescribeIsEnforced is the regression +// guard for codex P1 r3165508292: the goroutine used to gate against the +// datasource snapshot captured before auto-describe, so a user revocation +// during the (slow) describe phase was ignored. The fix re-reads the +// datasource right before Gate; this test toggles consent while +// DescribeEntity is parked and asserts the audit lands as a denial, not an +// allowed send. +func TestSensitivityScan_RevocationDuringDescribeIsEnforced(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + app.SchemaPrivacySetConsent(ds.ID, "allowed") + + root := t.TempDir() + app.entityCache = console.NewEntitySchemaCacheStore(filepath.Join(root, "entity-schema-cache.json")) + app.manager = console.NewManager() + adapter := &blockingDescribeAdapter{ + release: make(chan struct{}), + describes: make(chan string, 1), + } + app.manager.Register(ds.Type, adapter) + app.sensitivityMgr = sensitivity.NewManager( + sensitivity.NewStore(filepath.Join(root, "sensitivity.json")), + nil, + ) + + resp := app.SensitivityScan(ds.ID, "") + if status, _ := resp["status"].(string); status != "started" { + t.Fatalf("expected scan to start, got %#v", resp) + } + + // Wait for goroutine to enter DescribeEntity, then revoke consent + // while it is parked. After release, the gate must observe the new + // "denied" value and refuse the send. + select { + case <-adapter.describes: + case <-time.After(2 * time.Second): + close(adapter.release) + t.Fatal("timed out waiting for describe to start") + } + app.SchemaPrivacySetConsent(ds.ID, "denied") + close(adapter.release) + + // Wait until scan goroutine writes the denial audit and finishes. + deadline := time.Now().Add(3 * time.Second) + var items []schemaprivacy.AuditEntry + for time.Now().Before(deadline) { + var err error + items, err = app.schemaPrivacy.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) >= 1 { + break + } + time.Sleep(20 * time.Millisecond) + } + + if len(items) == 0 { + t.Fatalf("expected an audit entry after revocation; got none") + } + for _, it := range items { + if it.Status == schemaprivacy.StatusAllowed { + t.Fatalf("revocation must prevent allowed audit; got %#v", it) + } + } + if items[len(items)-1].Status != schemaprivacy.StatusDenied { + t.Fatalf("expected last audit entry to be denied, got %q", items[len(items)-1].Status) + } +} + +// TestSensitivityScan_DenyWritesAuditWithoutAllowedRow guards against the +// pre-fix behavior where SensitivityScan wrote an "allowed, 0 entities, 0 +// fields" audit row before it knew what the scan would actually send. With +// consent denied, only a denial entry should land — and crucially never an +// allowed entry. +func TestSensitivityScan_DenyWritesAuditWithoutAllowedRow(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + app.SchemaPrivacySetConsent(ds.ID, "denied") + + app.manager = console.NewManager() + app.manager.Register(ds.Type, appExecuteAdapterStub{}) + app.sensitivityMgr = sensitivity.NewManager( + sensitivity.NewStore(filepath.Join(t.TempDir(), "sensitivity.json")), + nil, + ) + + resp := app.SensitivityScan(ds.ID, "") + if _, ok := resp["error"]; !ok { + t.Fatalf("expected error when consent denied, got %#v", resp) + } + + items, err := app.schemaPrivacy.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected exactly 1 audit row (denial), got %d: %#v", len(items), items) + } + if items[0].Status != schemaprivacy.StatusDenied { + t.Fatalf("expected denied status, got %q", items[0].Status) + } + for _, it := range items { + if it.Status == schemaprivacy.StatusAllowed { + t.Fatalf("did not expect any allowed audit row, got %#v", it) + } + } +} + +// failingSchemaAdapter is the stub used by the preflight regression: every +// schema-fetching method returns an error so the test can assert that a +// denied/unset consent still produces ErrNotAllowed (not the adapter +// error) and a denied audit row. +type failingSchemaAdapter struct { + appExecuteAdapterStub + err error +} + +func (f failingSchemaAdapter) ListEntities(context.Context, datasource.DataSource, console.ListOptions) ([]string, error) { + return nil, f.err +} + +func (f failingSchemaAdapter) DescribeEntity(context.Context, datasource.DataSource, string) (console.DescribeResult, error) { + return console.DescribeResult{}, f.err +} + +// TestSchemaPrivacy_ChatGateRunsBeforeFetchOnDeniedConsent guards against +// codex P1 r3165...: if the AI Chat schema tools fetched first and gated +// after, a denied datasource whose underlying fetch errored out (missing +// cache, IO failure) would surface as a generic backend error and skip +// the denied-egress audit row. The fix preflights the gate so refusals +// fire before the fetch is even attempted. +func TestSchemaPrivacy_ChatGateRunsBeforeFetchOnDeniedConsent(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + app.SchemaPrivacySetConsent(ds.ID, "denied") + + manager := console.NewManager() + fetchErr := errors.New("schema cache unavailable") + manager.Register(ds.Type, failingSchemaAdapter{err: fetchErr}) + + tools := newAppAIChatTools(app.store, manager, nil, nil, nil, nil, app.schemaPrivacy, nil, nil).(*appAIChatTools) + + cases := []struct { + name string + call func() error + }{ + { + name: "ListEntities", + call: func() error { + _, err := tools.ListEntities(context.Background(), ds.ID, "", "") + return err + }, + }, + { + name: "DescribeEntity", + call: func() error { + _, err := tools.DescribeEntity(context.Background(), ds.ID, "users", "") + return err + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.call() + if err == nil { + t.Fatalf("expected refusal, got nil") + } + if !schemaprivacy.IsNotAllowed(err) { + t.Fatalf("expected schemaprivacy refusal, got %v", err) + } + if errors.Is(err, fetchErr) { + t.Fatalf("adapter fetch error must not surface when consent is denied: %v", err) + } + }) + } + + // Both calls must have produced denied audit rows even though no fetch + // ever succeeded; otherwise the audit log would silently underreport + // refused egress attempts. + items, err := app.schemaPrivacy.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 2 { + t.Fatalf("expected 2 denied audit rows (one per failed call), got %d: %#v", len(items), items) + } + for _, item := range items { + if item.Status != schemaprivacy.StatusDenied { + t.Fatalf("expected denied status, got %q", item.Status) + } + if item.Reason == "" { + t.Fatalf("denied row missing reason sentinel: %#v", item) + } + } +} + +// TestSchemaPrivacy_ChatPreflightSkipsStaleDeniedWhenFreshAllowed guards +// against codex P2 r3171...: if consent flips from denied/unset to allowed +// between the caller's store.Get and schemaPrivacyPreflight, the preflight +// used to enter schemaPrivacyGate against the stale snapshot. The inner +// gate would re-read, see the fresh "allowed", and write a phantom +// "allowed, 0 entities, 0 fields" audit row right before the post-fetch +// gate logged its own allowed row with real counts. The fix re-reads +// consent inside the preflight before checking ConsentOf so the +// short-circuit is decisive. +func TestSchemaPrivacy_ChatPreflightSkipsStaleDeniedWhenFreshAllowed(t *testing.T) { + app, ds := newSchemaPrivacyTestApp(t) + // The store reflects the user's freshly-granted consent. + if resp := app.SchemaPrivacySetConsent(ds.ID, "allowed"); resp["error"] != nil { + t.Fatalf("set consent: %v", resp["error"]) + } + + tools := newAppAIChatTools(app.store, nil, nil, nil, nil, nil, app.schemaPrivacy, nil, nil).(*appAIChatTools) + + // The caller still holds a stale "denied" snapshot from a Get that + // landed before the user flipped the toggle. + staleDS := ds + staleDS.Options = map[string]any{ + schemaprivacy.OptionKey: string(schemaprivacy.ConsentDenied), + } + + if err := tools.schemaPrivacyPreflight(context.Background(), staleDS, schemaprivacy.TriggerAIChatListEntities); err != nil { + t.Fatalf("preflight should short-circuit on fresh allowed consent; got %v", err) + } + items, err := app.schemaPrivacy.List(schemaprivacy.AuditFilter{DatasourceID: ds.ID}) + if err != nil { + t.Fatalf("audit list: %v", err) + } + if len(items) != 0 { + t.Fatalf("preflight must not write an audit row when fresh consent is allowed; got %d: %#v", len(items), items) + } +} diff --git a/app_secrets.go b/app_secrets.go new file mode 100644 index 0000000..b794ee9 --- /dev/null +++ b/app_secrets.go @@ -0,0 +1,42 @@ +package main + +import "futrixdata/platform/internal/secrets" + +// SecretProviderSummary is the non-secret view of a configured secret provider +// that the datasource form uses to populate the "existing secret" reference UI. +// It deliberately omits all auth material (tokens, RoleID/SecretID env names and +// file paths, agent sink configuration) so no secret-adjacent configuration +// leaks into UI payloads. +type SecretProviderSummary struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Default bool `json:"default"` + Address string `json:"address,omitempty"` + Mount string `json:"mount,omitempty"` +} + +// ListSecretProviders returns the configured secret providers as non-secret +// summaries. When no providers are configured (the default for most users), it +// returns an empty list and the datasource form keeps the plain manual-value flow. +func (a *App) ListSecretProviders() []SecretProviderSummary { + if a == nil || a.secretConfigs == nil { + return []SecretProviderSummary{} + } + configs := a.secretConfigs.List() + out := make([]SecretProviderSummary, 0, len(configs)) + for _, cfg := range configs { + summary := SecretProviderSummary{ + ID: cfg.ID, + Type: cfg.Type, + Name: cfg.Name, + Default: cfg.Default, + } + if cfg.Type == secrets.ProviderVaultKVV2 { + summary.Address = cfg.VaultKVV2.Address + summary.Mount = cfg.VaultKVV2.Mount + } + out = append(out, summary) + } + return out +} diff --git a/app_sensitivity.go b/app_sensitivity.go new file mode 100644 index 0000000..b361b07 --- /dev/null +++ b/app_sensitivity.go @@ -0,0 +1,556 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "sync" + "time" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/auth" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/planlimits" + "futrixdata/platform/internal/schemaprivacy" + "futrixdata/platform/internal/sensitivity" +) + +func (a *App) ensureSensitivityRulesAuthenticated() error { + if a == nil || a.authStore == nil { + return auth.ErrLoginRequired + } + state := a.authStore.Current() + if state.Session != nil { + return nil + } + if planlimits.PolicyManagementAllowed(effectivePlanForState(state, time.Now())) { + return nil + } + return auth.ErrLoginRequired +} + +// SensitivityGetCustomRules returns the persisted custom classification rules. +func (a *App) SensitivityGetCustomRules() map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + return map[string]any{"rules": a.sensitivityMgr.Store().GetCustomRules()} +} + +// SensitivitySetCustomRules saves custom classification rules for reuse across datasources. +func (a *App) SensitivitySetCustomRules(rules string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.ensureSensitivityRulesAuthenticated(); err != nil { + return map[string]any{"error": err.Error()} + } + if err := a.sensitivityMgr.Store().SetCustomRules(rules); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// SensitivityScan triggers an AI-powered sensitivity classification for a datasource. +func (a *App) SensitivityScan(datasourceID, aiConfigID string) map[string]any { + dsID := strings.TrimSpace(datasourceID) + if dsID == "" { + return map[string]any{"error": "datasource ID is required"} + } + + ds, ok := a.store.Get(dsID) + if !ok { + return map[string]any{"error": "datasource not found"} + } + + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + + // Resolve the provider/model up front so both the denial audit (if + // consent says no) and the allowed audit (written later, with real + // counts) describe the actual destination — not whichever default the + // resolver happens to pick at a different point in time. + provider, model, configID := "", "", "" + if resolver := providerSummaryFromResolver(a.aiConfigStore); resolver != nil { + provider, model, configID = resolver(strings.TrimSpace(aiConfigID)) + } + + // Pre-check consent. We deliberately do NOT call schemaprivacy.Gate on + // the allowed branch yet: Gate would write an audit row immediately, but + // at this point we don't know how many entities/fields will actually be + // sent (auto-describe and cache-rebuild happen in the goroutine below). + // Recording the audit early made every successful scan look like "sent + // 0 entities, 0 fields", and worse, it left an "allowed" entry on the + // books even when subsequent steps (no entities, no cache, scan already + // running) meant nothing was sent at all. So: deny audits go here, allow + // audits wait until we know what's leaving the box. + if schemaprivacy.ConsentOf(ds) != schemaprivacy.ConsentAllowed { + if gateErr := schemaprivacy.Gate(a.schemaPrivacy, ds, schemaprivacy.TriggerSensitivityScan, schemaprivacy.SendSummary{ + ProviderType: provider, + Model: model, + AIConfigID: configID, + }); gateErr != nil { + return map[string]any{"error": gateErr.Error()} + } + } + + cacheKey := entitySchemaCacheKey(ds) + entry, ok := a.entityCache.GetEntry(cacheKey) + if !ok || len(entry.Entities) == 0 { + // Auto-populate schema cache by listing entities from the datasource + if supportsEntitySchemaCache(ds) && a.entityCache != nil { + items, err := a.manager.ListEntities(context.Background(), ds, console.ListOptions{}) + if err != nil { + return map[string]any{"error": "failed to list entities: " + err.Error()} + } + if len(items) == 0 { + return map[string]any{"error": "no entities found in this datasource"} + } + a.upsertEntityCacheEntities(ds, cacheKey, items, nil) + entry, ok = a.entityCache.GetEntry(cacheKey) + if !ok || len(entry.Entities) == 0 { + return map[string]any{"error": "failed to populate schema cache"} + } + } else { + return map[string]any{"error": "no schema cache available — open the console for this datasource first"} + } + } + + // Count entities needing auto-describe for the response + _, initialSkipped := buildSchemaEntitiesFromCache(entry) + + // Reserve scan slot before launching goroutine to prevent duplicate scans + // during the potentially long auto-describe phase. + if !a.sensitivityMgr.TryBeginScan(dsID) { + return map[string]any{"status": "already_running", "datasourceId": dsID} + } + + trimmedAIConfigID := strings.TrimSpace(aiConfigID) + appCtx := a.ctx + go func() { + // Auto-describe entities missing column details so the scan covers all tables + a.describeUncachedEntities(ds, cacheKey, entry) + // Re-read the cache after describing + freshEntry, _ := a.entityCache.GetEntry(cacheKey) + + entities, skipped := buildSchemaEntitiesFromCache(freshEntry) + if len(entities) == 0 && skipped > 0 { + errMsg := "all_entities_skipped" + a.sensitivityMgr.SetProgressError(dsID, errMsg) + a.sensitivityMgr.EndScan(dsID) + if a.emitEvent != nil && appCtx.Err() == nil { + a.emitEvent(appCtx, "sensitivity:scan-complete", map[string]any{ + "datasourceId": dsID, + "progress": a.sensitivityMgr.GetProgress(dsID), + }) + } + return + } + + // Now that we know exactly what is about to be sent, run the + // (allowed) gate with real counts. Re-read the datasource from the + // store first: the snapshot we captured before the goroutine started + // is stale by up to several minutes (auto-describe time), and the + // user may have flipped consent from "allowed" to "denied" while we + // were busy. Without this reload, we would gate against the old + // snapshot, log an "allowed" audit row, and ship the schema in + // defiance of the revocation. + gateDS := ds + if fresh, ok := a.store.Get(dsID); ok { + gateDS = fresh + } + // Refuse-and-audit before any model probing: a revocation that + // landed mid-fetch must always produce a denial row regardless of + // model state. Doing this before ResolveModel keeps the security + // invariant — if we let model resolution gate the denial path, a + // missing AI config could mask a deliberate user revocation in the + // audit log. + if schemaprivacy.ConsentOf(gateDS) != schemaprivacy.ConsentAllowed { + denySummary := schemaprivacy.SendSummary{ + ProviderType: provider, + Model: model, + AIConfigID: configID, + } + if gateErr := schemaprivacy.Gate(a.schemaPrivacy, gateDS, schemaprivacy.TriggerSensitivityScan, denySummary); gateErr != nil { + a.sensitivityMgr.SetProgressError(dsID, gateErr.Error()) + } + a.sensitivityMgr.EndScan(dsID) + if a.emitEvent != nil && appCtx.Err() == nil { + a.emitEvent(appCtx, "sensitivity:scan-complete", map[string]any{ + "datasourceId": dsID, + "progress": a.sensitivityMgr.GetProgress(dsID), + }) + } + return + } + // Pre-resolve the model so the allowed audit only fires when an + // egress is actually possible. ScanPreLocked resolves the model + // later (manager.go:scanLocked); when no AI config is set or the + // provider is broken, that call fails before any chat request goes + // out. Writing an "allowed" audit row here for an egress that never + // happens corrupts later forensic queries — by the same reasoning + // the ER path was fixed in maybeGenerateER. + if err := a.sensitivityMgr.ResolveModel(trimmedAIConfigID); err != nil { + a.sensitivityMgr.SetProgressError(dsID, err.Error()) + a.sensitivityMgr.EndScan(dsID) + if a.emitEvent != nil && appCtx.Err() == nil { + a.emitEvent(appCtx, "sensitivity:scan-complete", map[string]any{ + "datasourceId": dsID, + "progress": a.sensitivityMgr.GetProgress(dsID), + }) + } + return + } + fieldCount := 0 + includesComments := false + for _, e := range entities { + fieldCount += len(e.Fields) + } + if gateErr := schemaprivacy.Gate(a.schemaPrivacy, gateDS, schemaprivacy.TriggerSensitivityScan, schemaprivacy.SendSummary{ + EntityCount: len(entities), + FieldCount: fieldCount, + IncludesComments: includesComments, + ProviderType: provider, + Model: model, + AIConfigID: configID, + }); gateErr != nil { + a.sensitivityMgr.SetProgressError(dsID, gateErr.Error()) + a.sensitivityMgr.EndScan(dsID) + if a.emitEvent != nil && appCtx.Err() == nil { + a.emitEvent(appCtx, "sensitivity:scan-complete", map[string]any{ + "datasourceId": dsID, + "progress": a.sensitivityMgr.GetProgress(dsID), + }) + } + return + } + schemaHash := "" + if a.schemaKB != nil { + snapshot, err := a.schemaKB.GetSchemaKnowledge(ds, "") + if err == nil { + if h, ok := snapshot["schemaHash"].(string); ok { + schemaHash = h + } + } + } + + customRules := a.sensitivityMgr.Store().GetCustomRules() + + input := sensitivity.ScanInput{ + DatasourceID: ds.ID, + DatasourceName: ds.Name, + DatasourceType: string(ds.Type), + Database: ds.Database, + SchemaHash: schemaHash, + AIConfigID: trimmedAIConfigID, + CustomRules: customRules, + Entities: entities, + PartialSchema: skipped > 0, + Force: true, // user-initiated scan always forces rescan of non-manual entities + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := a.sensitivityMgr.ScanPreLocked(ctx, input); err != nil { + if a.errorLog != nil { + a.errorLog.Printf("sensitivity scan failed for %s: %v", dsID, err) + } + } + if a.emitEvent != nil && appCtx.Err() == nil { + payload := map[string]any{ + "datasourceId": dsID, + "progress": a.sensitivityMgr.GetProgress(dsID), + } + if skipped > 0 { + payload["skippedEntities"] = skipped + payload["warning"] = "some tables could not be described and were skipped" + } + a.emitEvent(appCtx, "sensitivity:scan-complete", payload) + } + }() + + result := map[string]any{"status": "started", "datasourceId": dsID} + if initialSkipped > 0 { + result["describingEntities"] = initialSkipped + result["warning"] = "auto_describing" + } + return result +} + +// SensitivityGetProgress returns the current scan progress for a datasource. +func (a *App) SensitivityGetProgress(datasourceID string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + p := a.sensitivityMgr.GetProgress(strings.TrimSpace(datasourceID)) + if p == nil { + return map[string]any{"status": "idle"} + } + result := map[string]any{ + "datasourceId": p.DatasourceID, + "totalEntities": p.TotalEntities, + "scannedEntities": p.ScannedEntities, + "status": p.Status, + "error": p.Error, + } + if p.Entities != nil { + result["entities"] = p.Entities + } + return result +} + +// SensitivityGetReport returns the classification report for a datasource. +func (a *App) SensitivityGetReport(datasourceID string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + dc, ok := a.sensitivityMgr.Store().GetDatasource(strings.TrimSpace(datasourceID)) + if !ok { + return map[string]any{"found": false} + } + return map[string]any{ + "found": true, + "datasourceId": dc.DatasourceID, + "datasourceName": dc.DatasourceName, + "datasourceType": dc.DatasourceType, + "database": dc.Database, + "schemaHash": dc.SchemaHash, + "scannedAt": dc.ScannedAt, + "aiConfigId": dc.AIConfigID, + "entities": dc.Entities, + } +} + +// SensitivityConfirmField allows the user to manually confirm/override a field's classification. +func (a *App) SensitivityConfirmField(datasourceID, entityName, fieldName, level, category string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.ensureSensitivityRulesAuthenticated(); err != nil { + return map[string]any{"error": err.Error()} + } + entity := strings.TrimSpace(entityName) + field := strings.TrimSpace(fieldName) + if entity == "" || field == "" { + return map[string]any{"error": "entity name and field name are required"} + } + lvl := sensitivity.SensitivityLevel(strings.TrimSpace(level)) + levelCfg := a.sensitivityMgr.Store().GetLevelConfig() + validLevel := false + for _, l := range levelCfg.Levels { + if l.Key == string(lvl) { + validLevel = true + break + } + } + if !validLevel { + return map[string]any{"error": "invalid level"} + } + cat := sensitivity.Category(strings.TrimSpace(category)) + switch cat { + case sensitivity.CategoryPII, sensitivity.CategoryCredential, sensitivity.CategoryFinancial, + sensitivity.CategoryBehavioral, sensitivity.CategoryMedical, sensitivity.CategoryLocation, + sensitivity.CategoryContact, sensitivity.CategoryIdentifier, sensitivity.CategoryNone: + default: + return map[string]any{"error": "invalid category"} + } + err := a.sensitivityMgr.Store().ConfirmField( + strings.TrimSpace(datasourceID), + entity, + field, + lvl, cat, + ) + if err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// SensitivityGetMode returns the current whitelist/blacklist mode. +func (a *App) SensitivityGetMode() map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + return map[string]any{"mode": string(a.sensitivityMgr.Store().GetMode())} +} + +// SensitivitySetMode switches between whitelist and blacklist mode. +func (a *App) SensitivitySetMode(mode string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.ensureSensitivityRulesAuthenticated(); err != nil { + return map[string]any{"error": err.Error()} + } + m := sensitivity.ModeType(strings.TrimSpace(mode)) + if m != sensitivity.ModeWhitelist && m != sensitivity.ModeBlacklist { + return map[string]any{"error": "invalid mode, must be 'whitelist' or 'blacklist'"} + } + if err := a.sensitivityMgr.Store().SetMode(m); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// SensitivityGetLevelConfig returns the current sensitivity level configuration. +func (a *App) SensitivityGetLevelConfig() map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + cfg := a.sensitivityMgr.Store().GetLevelConfig() + return map[string]any{ + "levels": cfg.Levels, + "agentAccessFrom": cfg.AgentAccessFrom, + "agentAccessTo": cfg.AgentAccessTo, + } +} + +// SensitivitySetLevelConfig saves a new sensitivity level configuration. +func (a *App) SensitivitySetLevelConfig(levelsJSON string, agentAccessFrom, agentAccessTo int) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.ensureSensitivityRulesAuthenticated(); err != nil { + return map[string]any{"error": err.Error()} + } + var levels []sensitivity.LevelDefinition + if err := json.Unmarshal([]byte(levelsJSON), &levels); err != nil { + return map[string]any{"error": "invalid levels JSON: " + err.Error()} + } + cfg := sensitivity.LevelConfig{ + Levels: levels, + AgentAccessFrom: agentAccessFrom, + AgentAccessTo: agentAccessTo, + } + if err := a.sensitivityMgr.Store().SetLevelConfig(cfg); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// SensitivityResetLevelConfig restores the default sensitivity level configuration. +func (a *App) SensitivityResetLevelConfig() map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.ensureSensitivityRulesAuthenticated(); err != nil { + return map[string]any{"error": err.Error()} + } + cfg := sensitivity.DefaultLevelConfig() + if err := a.sensitivityMgr.Store().SetLevelConfig(cfg); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// SensitivityDeleteDatasource removes all classifications for a datasource. +func (a *App) SensitivityDeleteDatasource(datasourceID string) map[string]any { + if a.sensitivityMgr == nil { + return map[string]any{"error": "sensitivity manager not initialized"} + } + if err := a.sensitivityMgr.Store().DeleteDatasource(strings.TrimSpace(datasourceID)); err != nil { + return map[string]any{"error": err.Error()} + } + return map[string]any{"ok": true} +} + +// describeUncachedEntities fetches column details for entities missing from the cache. +// This allows sensitivity scan to work without requiring users to manually open each table. +func (a *App) describeUncachedEntities(ds datasource.DataSource, cacheKey string, entry console.EntitySchemaCacheEntry) { + var missing []string + for _, entityName := range entry.Entities { + detail, ok := entry.Details[entityName] + if !ok || len(detail.Columns) == 0 { + missing = append(missing, entityName) + } + } + if len(missing) == 0 { + return + } + // Cap total describe time to avoid holding the scan slot indefinitely + globalCtx, globalCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer globalCancel() + + const describeWorkers = 5 + ch := make(chan string, len(missing)) + for _, name := range missing { + ch <- name + } + close(ch) + + var wg sync.WaitGroup + for i := 0; i < describeWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for entityName := range ch { + if globalCtx.Err() != nil { + return + } + ctx, cancel := context.WithTimeout(globalCtx, 15*time.Second) + result, err := a.manager.DescribeEntity(ctx, ds, entityName) + cancel() + if err != nil { + continue + } + a.upsertEntityCacheDescribe(ds, cacheKey, entityName, result) + } + }() + } + wg.Wait() +} + +// buildSchemaEntitiesFromCache converts entity cache entries to schema entities +// containing only field names and data types (no actual data values). +// Returns the entities and the count of entities skipped due to missing column details. +func buildSchemaEntitiesFromCache(entry console.EntitySchemaCacheEntry) ([]sensitivity.SchemaEntity, int) { + var entities []sensitivity.SchemaEntity + skipped := 0 + for _, entityName := range entry.Entities { + detail, ok := entry.Details[entityName] + if !ok || len(detail.Columns) == 0 { + skipped++ + continue + } + fields := make([]sensitivity.SchemaField, 0, len(detail.Columns)) + for _, col := range detail.Columns { + fields = append(fields, sensitivity.SchemaField{ + Name: col.Name, + DataType: col.DataType, + }) + } + entities = append(entities, sensitivity.SchemaEntity{ + Entity: entityName, + Fields: fields, + }) + } + return entities, skipped +} + +// sensitivityModelBridge adapts aichat.ModelResolver to sensitivity.ModelResolver. +type sensitivityModelBridge struct { + resolver aichat.ModelResolver +} + +func (b *sensitivityModelBridge) Resolve(aiConfigID string) (sensitivity.Model, error) { + m, err := b.resolver.Resolve(aiConfigID) + if err != nil { + return nil, err + } + return &sensitivityModelWrapper{model: m}, nil +} + +// sensitivityModelWrapper wraps aichat.Model to satisfy sensitivity.Model. +type sensitivityModelWrapper struct { + model aichat.Model +} + +func (w *sensitivityModelWrapper) Chat(ctx context.Context, systemPrompt string, messages []sensitivity.ChatMessage) (string, error) { + aichatMsgs := make([]aichat.Message, len(messages)) + for i, m := range messages { + aichatMsgs[i] = aichat.Message{Role: m.Role, Content: m.Content} + } + return w.model.Chat(ctx, systemPrompt, aichatMsgs) +} diff --git a/app_skill.go b/app_skill.go new file mode 100644 index 0000000..865b6ec --- /dev/null +++ b/app_skill.go @@ -0,0 +1,276 @@ +package main + +import ( + "fmt" + "strings" + + "futrixdata/platform/internal/agentaudit" + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/skill" +) + +// --------------------------------------------------------------------------- +// Wails bindings — skill file management +// --------------------------------------------------------------------------- + +// DetectAIAgents scans the local machine for installed AI coding agents +// and reports whether the FutrixData skill is already installed for each. +func (a *App) DetectAIAgents() []skill.Agent { + return skill.DetectAgents() +} + +// InstallSkill writes skill files for the given agent IDs (e.g. "claude", "cursor"). +func (a *App) InstallSkill(agentIDs []string) skill.InstallResult { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + installPathByID := skillInstallPaths() + requests := make([]skill.SkillInstallRequest, 0, len(agentIDs)) + failures := make([]skill.AgentInstallOutcome, 0) + for _, raw := range agentIDs { + id := skill.AgentID(strings.TrimSpace(raw)) + name := skill.AgentDisplayName(id) + if !skill.IsSupportedAgentID(id) { + failures = append(failures, skill.AgentInstallOutcome{ID: id, Name: name, Error: "unknown agent: " + string(id)}) + continue + } + identity, err := store.EnsureForInstall(string(id), installPathByID[id], name) + if err != nil { + failures = append(failures, skill.AgentInstallOutcome{ID: id, Name: name, Error: err.Error()}) + continue + } + requests = append(requests, skill.SkillInstallRequest{AgentID: id, AccessKey: identity.AccessKey}) + } + result := skill.InstallSkillRequests(requests) + result.Installed = append(result.Installed, failures...) + return result +} + +func skillInstallPaths() map[skill.AgentID]string { + paths := map[skill.AgentID]string{} + for _, agent := range skill.DetectAgents() { + paths[agent.ID] = agent.InstallPath + } + return paths +} + +func mcpConfigPaths() map[skill.AgentID]string { + paths := map[skill.AgentID]string{} + for _, agent := range skill.DetectMCPAgents() { + paths[agent.ID] = agent.ConfigPath + } + return paths +} + +// UninstallSkill removes skill files for the given agent IDs. +func (a *App) UninstallSkill(agentIDs []string) skill.InstallResult { + return skill.UninstallSkill(agentIDs) +} + +// SkillInstallPrompted checks if the skill install prompt has been shown before. +func (a *App) SkillInstallPrompted() bool { + return skill.SkillPrompted(a.cfg.DataPath) +} + +// MarkSkillInstallPrompted marks the skill install prompt as having been shown. +func (a *App) MarkSkillInstallPrompted() error { + return skill.MarkSkillPrompted(a.cfg.DataPath) +} + +// --------------------------------------------------------------------------- +// Wails bindings — CLI binary distribution +// --------------------------------------------------------------------------- + +// CLIStatus reports whether futrixdata-cli is accessible in PATH. +func (a *App) CLIStatus() skill.CLIStatus { + return skill.CLIInPath() +} + +// InstallCLI places the CLI binary into PATH. +// cliPath is optional; if empty the binary is located automatically. +func (a *App) InstallCLI(cliPath string) error { + return skill.InstallCLI(cliPath) +} + +// UninstallCLI removes the CLI binary from PATH. +func (a *App) UninstallCLI() error { + return skill.UninstallCLI() +} + +// --------------------------------------------------------------------------- +// Wails bindings — MCP server configuration +// --------------------------------------------------------------------------- + +// DetectMCPAgents scans for AI agents that support MCP and checks if the +// FutrixData MCP server is configured in each. +func (a *App) DetectMCPAgents() []skill.MCPAgent { + return skill.DetectMCPAgents() +} + +// InstallMCP writes the FutrixData MCP server entry into the agent's config. +func (a *App) InstallMCP(agentIDs []string) skill.InstallResult { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + configPathByID := mcpConfigPaths() + requests := make([]skill.MCPInstallRequest, 0, len(agentIDs)) + failures := make([]skill.AgentInstallOutcome, 0) + for _, raw := range agentIDs { + id := skill.AgentID(strings.TrimSpace(raw)) + name := skill.AgentDisplayName(id) + if !skill.IsSupportedAgentID(id) { + failures = append(failures, skill.AgentInstallOutcome{ID: id, Name: name, Error: "unknown agent: " + string(id)}) + continue + } + identity, err := store.EnsureForInstall(string(id), configPathByID[id], name) + if err != nil { + failures = append(failures, skill.AgentInstallOutcome{ID: id, Name: name, Error: err.Error()}) + continue + } + requests = append(requests, skill.MCPInstallRequest{AgentID: id, AccessKey: identity.AccessKey}) + } + result := skill.InstallMCPRequests(requests) + result.Installed = append(result.Installed, failures...) + return result +} + +// AuthorizeCodexPlugin writes the local bridge used by the Codex plugin +// sidecar after the user confirms the deep-link authorization prompt. +func (a *App) AuthorizeCodexPlugin() skill.InstallResult { + return skill.AuthorizeCodexPlugin(a.cfg.DataPath) +} + +// UninstallMCP removes the FutrixData MCP server entry from the agent's config. +func (a *App) UninstallMCP(agentIDs []string) skill.InstallResult { + return skill.UninstallMCP(agentIDs) +} + +// GetManualInstallInfo returns the info needed to manually install the +// FutrixData skill and/or MCP server into any AI agent beyond the four +// preset integrations. +func (a *App) GetManualInstallInfo() (skill.ManualInstallInfo, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + // EnsureManual is idempotent — reopening the manual install panel must + // not mint a new key every time. CreateManual would do exactly that, + // leaving orphan identities littering the audit page. + identity, err := store.EnsureManual("") + if err != nil { + return skill.ManualInstallInfo{}, err + } + return skill.GetManualInstallInfoForAgent(identity.AccessKey, identity.Name), nil +} + +// GetManualInstallInfoForKey returns the install snippets bound to a specific +// existing manual identity. Lets the manual-install dialog switch between the +// distinct keys a user has minted for different downstream agents without +// minting a new identity each time. +func (a *App) GetManualInstallInfoForKey(accessKey string) (skill.ManualInstallInfo, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + identity, ok, err := store.Get(accessKey) + if err != nil { + return skill.ManualInstallInfo{}, err + } + if !ok { + return skill.ManualInstallInfo{}, fmt.Errorf("agent identity not found") + } + return skill.GetManualInstallInfoForAgent(identity.AccessKey, identity.Name), nil +} + +// CreateManualAgent mints a brand-new manual identity. Use this when a user +// wants distinct access keys per downstream agent (e.g. one for Zed, one for +// a custom harness) so revoking one does not silently disconnect the other. +// The idempotent EnsureManual path is preserved for the default "open the +// manual install dialog" flow; this is for explicit "I want another agent" UX. +func (a *App) CreateManualAgent(name string) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.CreateManual(name) +} + +func (a *App) RenameAgentIdentity(accessKey string, name string) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.Rename(accessKey, name) +} + +// RevokeAgentIdentity marks an identity as revoked so the CLI / MCP runtime +// rejects its tool calls. Audit history remains visible; the UI surfaces the +// revoked state alongside the agent name. +func (a *App) RevokeAgentIdentity(accessKey string) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.Revoke(accessKey) +} + +// UnrevokeAgentIdentity clears the revoked state on an identity. +func (a *App) UnrevokeAgentIdentity(accessKey string) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.Unrevoke(accessKey) +} + +// SetAgentSensitivityGrant flips the per-identity grant that controls access +// to the sensitivity-policy write tools. Surfaced from the manage UI and +// from the new-manual-agent dialog so the user can opt the agent in or out +// at any time. The toolexec dispatch chokepoint enforces the gate; this +// method only persists the flag. +func (a *App) SetAgentSensitivityGrant(accessKey string, grant bool) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.SetSensitivityGrant(accessKey, grant) +} + +// SetAgentDatasourceManagementGrant flips the per-identity grant that lets an +// agent create datasources through the Skill/MCP tool surface. It does not +// grant update/delete permission and the tool dispatcher still rejects +// trusted/danger trust levels on autonomous create payloads. +func (a *App) SetAgentDatasourceManagementGrant(accessKey string, grant bool) (agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.SetDatasourceManagementGrant(accessKey, grant) +} + +// ListAgentIdentities returns every recorded identity. Useful for the Skill / +// MCP management UI, which joins detection output with identity metadata on +// the client so detection remains a pure filesystem scan. +func (a *App) ListAgentIdentities() ([]agentaudit.AgentIdentity, error) { + store := agentaudit.NewIdentityStore(bootstrap.AgentIdentityPath(a.cfg.DataPath)) + return store.ListAll() +} + +// --------------------------------------------------------------------------- +// Startup hook — called from App.startup() +// --------------------------------------------------------------------------- + +// ensureCLIInPath makes the bundled CLI binary available in PATH on startup. +// Production: finds CLI next to the app binary and symlinks/copies to CLIInstallDir. +// Dev mode: scripts/build.sh is expected to have built the CLI already. +func (a *App) ensureCLIInPath() { + status := skill.CLIInPath() + if status.InPath && skill.ValidateManagedCLIInstall() == nil { + a.refreshInstalledSkills() + return + } + // Binary may exist in CLIInstallDir but not yet in PATH (e.g. Windows before registry update). + if status.BinaryPath == "" || skill.ValidateManagedCLIInstall() != nil { + if err := skill.InstallCLI(""); err != nil { + a.logErrorf("source=skill event=cli_install_skipped error=%s", logField(err.Error())) + a.refreshInstalledSkills() + return + } + } + if err := skill.EnsureInSystemPath(); err != nil { + a.logErrorf("source=skill event=ensure_path_failed error=%s", logField(err.Error())) + } + a.refreshInstalledSkills() +} + +func (a *App) refreshInstalledSkills() { + result := skill.RefreshInstalledSkills(a.cfg.DataPath) + for _, outcome := range result.Installed { + if outcome.Success { + a.logInfof("source=skill event=skill_auto_updated agent=%s path=%s", outcome.ID, logField(outcome.Path)) + continue + } + a.logErrorf("source=skill event=skill_auto_update_failed agent=%s path=%s error=%s", outcome.ID, logField(outcome.Path), logField(outcome.Error)) + } + + mcpResult := skill.RefreshInstalledMCP(a.cfg.DataPath) + for _, outcome := range mcpResult.Installed { + if outcome.Success { + a.logInfof("source=skill event=mcp_auto_updated agent=%s path=%s", outcome.ID, logField(outcome.Path)) + continue + } + a.logErrorf("source=skill event=mcp_auto_update_failed agent=%s path=%s error=%s", outcome.ID, logField(outcome.Path), logField(outcome.Error)) + } +} diff --git a/app_updater.go b/app_updater.go new file mode 100644 index 0000000..a8d5c15 --- /dev/null +++ b/app_updater.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "net/url" + "strings" + + "futrixdata/platform/internal/updater" + + "github.com/pkg/browser" +) + +// CheckForUpdate asks FutrixServer for the latest released version and +// returns the comparison result. The frontend calls this on app start +// (after auth is ready) and on demand from the Settings page. +// +// When the user is not signed in the result is returned with +// Authenticated=false and a nil error so the frontend can render a +// "sign in to check" hint instead of an error banner. +func (a *App) CheckForUpdate() (updater.Result, error) { + if a.updaterService == nil { + return updater.Result{}, errors.New("updater service is not configured") + } + return a.updaterService.CheckLatest(context.Background()) +} + +// OpenUpdateDownload hands the download URL to the system browser. The URL +// is whatever CheckForUpdate returned for the running OS/arch — the +// frontend passes it back through this call so we keep one resolved +// platform-key path. We validate it points at the configured FutrixServer +// host before opening, to avoid being a generic open-anything sink. +func (a *App) OpenUpdateDownload(rawURL string) error { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return errors.New("download url is required") + } + if !isAllowedUpdateURL(rawURL, a.cfg) { + return errors.New("download url is not on an allowed host") + } + return browser.OpenURL(rawURL) +} + +// isAllowedUpdateURL guards OpenUpdateDownload against being asked to open +// arbitrary URLs the frontend was tricked into sending. We only allow URLs +// served from the configured FutrixServer host (or its default). +func isAllowedUpdateURL(rawURL string, cfg Config) bool { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + if parsed.Scheme != "https" && parsed.Scheme != "http" { + return false + } + allowed := map[string]struct{}{} + if base := strings.TrimSpace(resolveAuthBaseURL(cfg)); base != "" { + if u, err := url.Parse(base); err == nil && u.Host != "" { + allowed[u.Host] = struct{}{} + } + } + if u, err := url.Parse("https://futrixdata.com"); err == nil { + allowed[u.Host] = struct{}{} + } + _, ok := allowed[parsed.Host] + return ok +} diff --git a/app_updater_test.go b/app_updater_test.go new file mode 100644 index 0000000..8b3f2e9 --- /dev/null +++ b/app_updater_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "testing" +) + +func TestIsAllowedUpdateURL(t *testing.T) { + const envKey = "FUTRIX_AUTH_BASE_URL" + + tests := []struct { + name string + rawURL string + cfg Config + envVal string + setEnv bool + want bool + }{ + { + name: "default futrixdata host always allowed", + rawURL: "https://futrixdata.com/api/download/macos-arm64", + cfg: Config{}, + want: true, + }, + { + name: "config AuthBaseURL host allowed", + rawURL: "https://staging.example.com/api/download/macos-arm64", + cfg: Config{AuthBaseURL: "https://staging.example.com"}, + want: true, + }, + { + name: "env FUTRIX_AUTH_BASE_URL host allowed when cfg empty", + rawURL: "https://dev.example.com/api/download/linux-amd64", + cfg: Config{}, + envVal: "https://dev.example.com", + setEnv: true, + want: true, + }, + { + name: "unrelated host rejected", + rawURL: "https://evil.example.org/binary", + cfg: Config{AuthBaseURL: "https://staging.example.com"}, + want: false, + }, + { + name: "non-http scheme rejected", + rawURL: "javascript:alert(1)", + cfg: Config{}, + want: false, + }, + { + name: "empty url rejected", + rawURL: "", + cfg: Config{}, + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setEnv { + t.Setenv(envKey, tc.envVal) + } else { + t.Setenv(envKey, "") + } + got := isAllowedUpdateURL(tc.rawURL, tc.cfg) + if got != tc.want { + t.Fatalf("isAllowedUpdateURL(%q) = %v, want %v", tc.rawURL, got, tc.want) + } + }) + } +} diff --git a/app_userkb.go b/app_userkb.go new file mode 100644 index 0000000..5d21524 --- /dev/null +++ b/app_userkb.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "errors" + "strings" + + "futrixdata/platform/internal/aichat" + "futrixdata/platform/internal/userkb" +) + +type userKBModel struct { + delegate aichat.Model +} + +func (m userKBModel) Chat(ctx context.Context, systemPrompt string, messages []userkb.Message) (string, error) { + if m.delegate == nil { + return "", errors.New("ai provider not configured") + } + converted := make([]aichat.Message, 0, len(messages)) + for _, msg := range messages { + role := strings.TrimSpace(msg.Role) + if role == "" { + role = "user" + } + converted = append(converted, aichat.Message{Role: role, Content: msg.Content}) + } + return m.delegate.Chat(ctx, systemPrompt, converted) +} + +type userKBModelResolver struct { + delegate aichat.ModelResolver +} + +func newUserKBModelResolver(delegate aichat.ModelResolver) userkb.ModelResolver { + if delegate == nil { + return nil + } + return userKBModelResolver{delegate: delegate} +} + +func (r userKBModelResolver) Resolve(aiConfigID string) (userkb.Model, error) { + if r.delegate == nil { + return nil, errors.New("ai provider not configured") + } + model, err := r.delegate.Resolve(aiConfigID) + if err != nil { + return nil, err + } + return userKBModel{delegate: model}, nil +} + +func (a *App) UserKBList() (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.List(ctx) +} + +func (a *App) UserKBCreateCategory(input userkb.CategoryCreateInput) (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.CreateCategory(ctx, input) +} + +func (a *App) UserKBUpdateCategory(id string, input userkb.CategoryUpdateInput) (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.UpdateCategory(ctx, id, input) +} + +func (a *App) UserKBDeleteCategory(id string) (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.DeleteCategory(ctx, id) +} + +func (a *App) UserKBUploadFiles(categoryID string, files []userkb.UploadFileInput) (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.UploadFiles(ctx, categoryID, files, "") +} + +func (a *App) UserKBDeleteFile(fileID string) (userkb.ViewState, error) { + if a.userKB == nil { + return userkb.ViewState{}, errors.New("user knowledge base is not available") + } + ctx := a.ctx + if ctx == nil { + ctx = context.Background() + } + return a.userKB.DeleteFile(ctx, fileID) +} diff --git a/app_version.go b/app_version.go new file mode 100644 index 0000000..ecae64b --- /dev/null +++ b/app_version.go @@ -0,0 +1,7 @@ +package main + +import "futrixdata/platform/internal/version" + +func (a *App) GetAppVersion() string { + return version.Version +} diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..9131708 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/logo.svg b/build/logo.svg new file mode 100644 index 0000000..300cdbf --- /dev/null +++ b/build/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/cmd/futrix-audit-verify/main.go b/cmd/futrix-audit-verify/main.go deleted file mode 100644 index c731551..0000000 --- a/cmd/futrix-audit-verify/main.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/FutrixDev/FutrixPackage/pkg/auditchain" -) - -func main() { - if len(os.Args) != 2 { - fmt.Fprintln(os.Stderr, "usage: futrix-audit-verify ") - os.Exit(2) - } - result, err := auditchain.VerifyFile(os.Args[1]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(result); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if !result.Pass { - os.Exit(1) - } -} diff --git a/cmd/futrix-evidence-verify/main.go b/cmd/futrix-evidence-verify/main.go deleted file mode 100644 index 5f9784d..0000000 --- a/cmd/futrix-evidence-verify/main.go +++ /dev/null @@ -1,30 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - - "github.com/FutrixDev/FutrixPackage/pkg/evidence" -) - -func main() { - if len(os.Args) != 2 { - fmt.Fprintln(os.Stderr, "usage: futrix-evidence-verify ") - os.Exit(2) - } - result, err := evidence.VerifyBundle(os.Args[1]) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(result); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - if !result.Pass { - os.Exit(1) - } -} diff --git a/cmd/futrixdata-cli/main.go b/cmd/futrixdata-cli/main.go new file mode 100644 index 0000000..b741f42 --- /dev/null +++ b/cmd/futrixdata-cli/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + "futrixdata/platform/internal/cli" +) + +func main() { + runner := cli.NewRunner(os.Stdout, os.Stderr) + os.Exit(runner.Run(os.Args[1:])) +} diff --git a/cmd/http/main.go b/cmd/http/main.go new file mode 100644 index 0000000..8e76876 --- /dev/null +++ b/cmd/http/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "futrixdata/platform/internal/ai" + "futrixdata/platform/internal/aiconfig" + "futrixdata/platform/internal/appdata" + "futrixdata/platform/internal/bootstrap" + "futrixdata/platform/internal/console" + "futrixdata/platform/internal/datasource" + "futrixdata/platform/internal/localcrypto" + "futrixdata/platform/internal/observability" + "futrixdata/platform/internal/platform" +) + +type Config struct { + Address string `json:"address" yaml:"address"` + DataPath string `json:"data_path" yaml:"data_path"` + AIBaseURL string `json:"ai_base_url" yaml:"ai_base_url"` + AIAPIKey string `json:"ai_api_key" yaml:"ai_api_key"` + AIModel string `json:"ai_model" yaml:"ai_model"` + AITimeoutSeconds int `json:"ai_timeout_seconds" yaml:"ai_timeout_seconds"` +} + +func loadConfig(path string) (Config, error) { + defaultDataPath := appdata.DevDataPath("FutrixData") + cfg := Config{ + Address: ":8080", + DataPath: defaultDataPath, + AIBaseURL: "https://api.openai.com/v1", + AIModel: "gpt-5.2", + AITimeoutSeconds: 15, + } + if path == "" { + return cfg, nil + } + + content, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + + switch ext := filepath.Ext(path); ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(content, &cfg); err != nil { + return cfg, err + } + default: + if err := json.Unmarshal(content, &cfg); err != nil { + return cfg, err + } + } + + if cfg.Address == "" { + cfg.Address = ":8080" + } + if cfg.DataPath == "" { + cfg.DataPath = defaultDataPath + } + if !filepath.IsAbs(cfg.DataPath) { + cfg.DataPath = filepath.Join(filepath.Dir(path), cfg.DataPath) + } + return cfg, nil +} + +func aiConfig(cfg Config) ai.Config { + baseURL := firstNonEmpty(cfg.AIBaseURL, os.Getenv("FUTRIX_AI_BASE_URL")) + if baseURL == "" { + baseURL = "https://api.openai.com/v1" + } + apiKey := firstNonEmpty(cfg.AIAPIKey, os.Getenv("FUTRIX_AI_API_KEY"), os.Getenv("OPENAI_API_KEY")) + model := firstNonEmpty(cfg.AIModel, os.Getenv("FUTRIX_AI_MODEL")) + if model == "" { + model = "gpt-5.2" + } + timeout := time.Duration(cfg.AITimeoutSeconds) * time.Second + return ai.Config{ + BaseURL: baseURL, + APIKey: apiKey, + Model: model, + Timeout: timeout, + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func main() { + configPath := flag.String("config", "", "path to config file") + flag.Parse() + + cfg, err := loadConfig(*configPath) + if err != nil { + log.Fatalf("load config: %v", err) + } + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.SetOutput(observability.NewLevelWriter(observability.Config{ + RootDir: filepath.Join(filepath.Dir(cfg.DataPath), "logs"), + FileName: "info.log", + MaxBytes: 50 * 1024 * 1024, + RotateBytes: 5 * 1024 * 1024, + })) + // Best-effort aux migration mirrors NewRuntime's load policies below: a corrupt + // optional encrypted file (history, entity cache, secret-providers.json, ...) + // must not stop the server from coming up on the datasource store. + if _, err := localcrypto.InitWithOptions(cfg.DataPath, localcrypto.InitOptions{ + AuxiliaryLoadMode: bootstrap.AuxiliaryLoadBestEffort, + }); err != nil { + log.New(observability.NewLevelWriter(observability.Config{ + RootDir: filepath.Join(filepath.Dir(cfg.DataPath), "logs"), + FileName: "error.log", + MaxBytes: 50 * 1024 * 1024, + RotateBytes: 5 * 1024 * 1024, + }), "", log.LstdFlags|log.Lmicroseconds).Printf("source=startup event=localcrypto_init_failed error=%q", err.Error()) + log.Fatalf("init local crypto: %v", err) + } + + srv := platform.NewServer(cfg.Address) + srv.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + platform.WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + runtimeBundle, err := bootstrap.NewRuntime(bootstrap.Config{ + DataPath: cfg.DataPath, + RedisDocsLoadPolicy: bootstrap.LoadPolicyBestEffort, + EntityCacheLoadPolicy: bootstrap.LoadPolicyBestEffort, + HistoryLoadPolicy: bootstrap.LoadPolicyBestEffort, + SecretConfigLoadPolicy: bootstrap.LoadPolicyBestEffort, + }) + if err != nil { + log.New(observability.NewLevelWriter(observability.Config{ + RootDir: filepath.Join(filepath.Dir(cfg.DataPath), "logs"), + FileName: "error.log", + MaxBytes: 50 * 1024 * 1024, + RotateBytes: 5 * 1024 * 1024, + }), "", log.LstdFlags|log.Lmicroseconds).Printf("source=startup event=load_runtime_failed error=%q", err.Error()) + log.Fatalf("load runtime: %v", err) + } + cfg.DataPath = runtimeBundle.DataPath + store := runtimeBundle.Store + aiConfigStore := runtimeBundle.AIConfigStore + manager := runtimeBundle.Manager + + dsHandler := datasource.NewHandler(store) + consoleHandler := console.NewHandler(store, manager) + dsHandler.SetSubrouter(consoleHandler) + dsHandler.SetTester(func(ds datasource.DataSource) error { + return manager.TestConnection(context.Background(), ds) + }) + dsHandler.RegisterRoutes(srv) + + // Register AI config routes + aiCfgHandler := aiconfig.NewHandler(aiConfigStore) + aiTester := func(cfg aiconfig.AIConfig) aiconfig.TestResult { + return aiconfig.TestConnection(context.Background(), cfg) + } + assignDefaults := func(cfg aiconfig.AIConfig, result aiconfig.TestResult) { + if !result.Connected { + return + } + _, _ = store.AssignAIConfigIfUnset(cfg.ID) + } + aiCfgHandler.SetTester(aiTester) + aiCfgHandler.SetStatusObserver(assignDefaults) + aiconfig.StartMonitor(context.Background(), aiConfigStore, aiTester, 30*time.Minute, assignDefaults) + aiCfgHandler.RegisterRoutes(srv) + + // AI assistant handler with dynamic config support + aiHandler := ai.NewHandler(store, aiConfigStore, ai.NewClient(aiConfig(cfg))) + srv.Handle("/api/ai/", aiHandler) + + log.Printf("listening on %s", cfg.Address) + if err := http.ListenAndServe(cfg.Address, srv); err != nil { + log.New(observability.NewLevelWriter(observability.Config{ + RootDir: filepath.Join(filepath.Dir(cfg.DataPath), "logs"), + FileName: "error.log", + MaxBytes: 50 * 1024 * 1024, + RotateBytes: 5 * 1024 * 1024, + }), "", log.LstdFlags|log.Lmicroseconds).Printf("source=runtime event=http_serve_failed error=%q", err.Error()) + log.Fatalf("serve: %v", err) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..ab271e9 --- /dev/null +++ b/config.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + + "futrixdata/platform/internal/appdata" + "futrixdata/platform/internal/auth" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Address string `json:"address" yaml:"address"` + DataPath string `json:"data_path" yaml:"data_path"` + AuthBaseURL string `json:"auth_base_url" yaml:"auth_base_url"` + AIBaseURL string `json:"ai_base_url" yaml:"ai_base_url"` + AIAPIKey string `json:"ai_api_key" yaml:"ai_api_key"` + AIModel string `json:"ai_model" yaml:"ai_model"` + AITimeoutSeconds int `json:"ai_timeout_seconds" yaml:"ai_timeout_seconds"` + AIChatPromptPath string `json:"ai_chat_prompt_path" yaml:"ai_chat_prompt_path"` + AIChatPromptModulesDir string `json:"ai_chat_prompt_modules_dir" yaml:"ai_chat_prompt_modules_dir"` + AIChatKnowledgeDir string `json:"ai_chat_knowledge_dir" yaml:"ai_chat_knowledge_dir"` +} + +func loadConfig(path string) (Config, error) { + defaultDataPath := appdata.DevDataPath("FutrixData") + cfg := Config{ + Address: ":8080", + DataPath: defaultDataPath, + AuthBaseURL: auth.DefaultBaseURL, + AIBaseURL: "https://api.openai.com/v1", + AIModel: "gpt-5.2", + AITimeoutSeconds: 15, + AIChatPromptPath: "data/ai-chat-system-prompt.md", + AIChatPromptModulesDir: "data/ai-chat-prompts", + AIChatKnowledgeDir: "data/ai-chat-knowledge", + } + if path == "" { + return cfg, nil + } + + content, err := os.ReadFile(path) + if err != nil { + return cfg, err + } + + switch ext := filepath.Ext(path); ext { + case ".yaml", ".yml": + if err := yaml.Unmarshal(content, &cfg); err != nil { + return cfg, err + } + default: + if err := json.Unmarshal(content, &cfg); err != nil { + return cfg, err + } + } + + if cfg.DataPath == "" { + cfg.DataPath = defaultDataPath + } + if !filepath.IsAbs(cfg.DataPath) { + cfg.DataPath = filepath.Join(filepath.Dir(path), cfg.DataPath) + } + if cfg.AIBaseURL == "" { + cfg.AIBaseURL = "https://api.openai.com/v1" + } + if cfg.AuthBaseURL == "" { + cfg.AuthBaseURL = os.Getenv("FUTRIX_AUTH_BASE_URL") + } + if cfg.AuthBaseURL == "" { + cfg.AuthBaseURL = auth.DefaultBaseURL + } + if cfg.AIModel == "" { + cfg.AIModel = "gpt-5.2" + } + if cfg.AITimeoutSeconds == 0 { + cfg.AITimeoutSeconds = 15 + } + if cfg.AIChatPromptPath == "" { + cfg.AIChatPromptPath = "data/ai-chat-system-prompt.md" + } + if cfg.AIChatPromptModulesDir == "" { + cfg.AIChatPromptModulesDir = "data/ai-chat-prompts" + } + if cfg.AIChatKnowledgeDir == "" { + cfg.AIChatKnowledgeDir = "data/ai-chat-knowledge" + } + return cfg, nil +} diff --git a/config_auth_test.go b/config_auth_test.go new file mode 100644 index 0000000..80cbf53 --- /dev/null +++ b/config_auth_test.go @@ -0,0 +1,15 @@ +package main + +import "testing" + +func TestLoadConfigDefaultsAuthBaseURLToProductionDomain(t *testing.T) { + t.Setenv("FUTRIX_AUTH_BASE_URL", "") + + cfg, err := loadConfig("") + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.AuthBaseURL != "https://futrixdata.com" { + t.Fatalf("expected production auth base url, got %q", cfg.AuthBaseURL) + } +} diff --git a/docs/agent-protocol.md b/docs/agent-protocol.md deleted file mode 100644 index a5647d1..0000000 --- a/docs/agent-protocol.md +++ /dev/null @@ -1,72 +0,0 @@ -# Agent protocol - -## Tool call envelope - -```json -{ - "tool": "execute_statement", - "accessKey": "fxd_live_...", - "protocol": "mcp", - "params": { - "datasourceId": "prod-postgres", - "statement": "select * from users where id = 1042" - } -} -``` - -## Success response - -```json -{ - "tool": "execute_statement", - "ok": true, - "result": { - "rows": [] - }, - "auditId": "audit_01HQ..." -} -``` - -## Approval response - -```json -{ - "tool": "execute_statement", - "ok": false, - "approvalRequired": { - "tool": "execute_statement", - "summary": "Execute statement on datasource \"prod-postgres\"", - "riskAttribution": { - "source": "risk_engine", - "action": "warn", - "level": "medium", - "ruleId": "sql-warn-update", - "ruleCode": "SQL-008", - "reasons": ["UPDATE"] - } - } -} -``` - -## Error response - -```json -{ - "tool": "execute_statement", - "ok": false, - "error": { - "code": "tool_error", - "message": "DELETE without WHERE", - "riskAttribution": { - "source": "risk_engine", - "action": "block", - "level": "high", - "ruleId": "sql-block-delete-no-where" - } - } -} -``` - -## Public tool names - -The public package documents the stable tool names in `pkg/protocol`. The commercial product implements the full transport adapters for MCP, CLI, Skill, HTTP, and daemon IPC. diff --git a/docs/assurance-matrix.md b/docs/assurance-matrix.md deleted file mode 100644 index 0ee22ea..0000000 --- a/docs/assurance-matrix.md +++ /dev/null @@ -1,21 +0,0 @@ -# Assurance matrix - -This matrix maps FutrixData's public security claims to public code, commercial product behavior, and a buyer-verifiable check. - -| Security claim | Public code / evidence | Commercial product feature | How to verify | -| --- | --- | --- | --- | -| Agents get data without receiving database credentials. | `pkg/protocol` documents the agent tool contract without credential fields. | Desktop and Enterprise agent paths route calls through MCP, Skill, CLI, or HTTP-style tools instead of exposing connection strings. | Review `pkg/protocol.PublicTools()` and confirm tool calls use `datasourceId`, not passwords or connection strings. In product review, inspect an agent config and verify it only holds a FutrixData agent key. | -| Risky statements are evaluated before execution. | `pkg/riskengine` opens the portable risk-engine core: parser, rules, priority, and evaluator. `examples/product-export/risk-block-response.json` shows a blocked statement. | Commercial runtime adds datasource trust levels, richer parser integrations, EXPLAIN probes, approval routing, and daemon cache behavior. | Run `go test ./pkg/riskengine` and `go run ./cmd/futrix-evidence-verify ./examples/product-export`. In product review, execute a destructive statement such as `DELETE FROM users` and confirm it is blocked before DB execution. | -| Sensitive fields are masked before agent results are returned. | `pkg/masking` opens the L1-L5 model and deterministic `masked:v1:` algorithm. `examples/product-export/masked-query-result.json` shows an agent-facing result with masked columns. | Desktop and Enterprise apply masking on agent result paths while the human console may still show raw rows to authorized users. | Run `go test ./pkg/masking` and `go run ./cmd/futrix-evidence-verify ./examples/product-export`. In product review, classify `email` as L4/L5, run an agent query, and confirm `maskedColumns` and `masked:v1:` values appear. | -| Agent calls are attributed and auditable. | `pkg/protocol` exposes `auditId` and `riskAttribution` response fields. `examples/product-export/audit-log.jsonl` contains a chained audit export. | Commercial runtime records agent identity, protocol, tool, datasource, status, risk attribution, and timestamps. | Run `go run ./cmd/futrix-audit-verify ./examples/product-export/audit-log.jsonl`. In product review, perform one MCP/Skill/CLI agent call and verify an audit row is created. | -| Audit logs are locally tamper-evident. | `pkg/auditchain` and `cmd/futrix-audit-verify` implement the local SHA-256 hash-chain verifier. | Product audit rows include `seq`, `prev_hash`, `payload_hash`, `chain_hash`, and `chain_version`. | Run `go run ./cmd/futrix-audit-verify `. Modify one row and confirm the verifier fails. | -| Approval responses explain why a call was held. | `pkg/protocol.RiskAttribution` and `examples/product-export/approval-response.json` expose matched rule details. | Commercial runtime returns approval prompts with risk attribution for MCP, Skill, CLI, and Enterprise entry points where supported. | Run `go run ./cmd/futrix-evidence-verify ./examples/product-export`. In product review, run `UPDATE users SET ... WHERE ...` under a cautious policy and confirm `approvalRequired.riskAttribution` is present. | -| Release artifacts can be checked after download. | `release-verification/verify-checksums.sh` verifies `SHA256SUMS.txt`. | Release workflow publishes platform artifacts and checksums. | Download artifacts and run `bash ./release-verification/verify-checksums.sh `. | -| The public repository is useful but not enough to rebuild FutrixData. | `docs/open-source-scope.md` and `docs/production-consistency.md` define the boundary. | Proprietary code keeps desktop UI, datasource adapters, credentials, account/license, Enterprise deployment, SSO/RBAC, signing, and release infrastructure private. | Review repository layout and confirm no datasource adapter, desktop shell, Enterprise server, billing, license, or signing secret code is present. | - -## Buyer review path - -1. Run the public test suite: `go test ./...`. -2. Verify the evidence bundle: `go run ./cmd/futrix-evidence-verify ./examples/product-export`. -3. Verify a product-exported audit log: `go run ./cmd/futrix-audit-verify `. -4. During product evaluation, reproduce one masked query, one blocked query, and one approval-required query against a disposable datasource. diff --git a/docs/audit-chain.md b/docs/audit-chain.md deleted file mode 100644 index ef9f215..0000000 --- a/docs/audit-chain.md +++ /dev/null @@ -1,63 +0,0 @@ -# Audit-chain specification - -## Record format - -FutrixData audit logs are JSONL. New chained rows contain these fields: - -| Field | Meaning | -| --- | --- | -| `seq` | Physical non-empty row number, starting at 1. | -| `prev_hash` | Previous chained row hash, or the genesis hash for the first chained row. | -| `payload_hash` | SHA-256 of the canonical row payload after removing chain fields. | -| `chain_hash` | SHA-256 of `seq`, `prev_hash`, `payload_hash`, and `chain_version`. | -| `chain_version` | Current value: `local-sha256-v1`. | - -Rows without any chain fields are legacy rows. A legacy prefix is accepted. Once the chain starts, later legacy rows fail verification. - -## Canonical payload - -To compute `payload_hash`: - -1. Parse the JSON row. -2. Remove `seq`, `prev_hash`, `payload_hash`, `chain_hash`, and `chain_version`. -3. JSON-encode the resulting object using Go's standard JSON encoder. -4. Hash the encoded bytes with SHA-256. - -## Chain hash - -To compute `chain_hash`, JSON-encode: - -```json -{ - "chain_version": "local-sha256-v1", - "payload_hash": "", - "prev_hash": "", - "seq": 1 -} -``` - -Then hash the encoded bytes with SHA-256. - -## Verification - -Run: - -```bash -go run ./cmd/futrix-audit-verify ./examples/audit-log/valid.jsonl -``` - -The JSON result reports: - -- `pass`; -- `verified_records`; -- `legacy_records`; -- `total_records`; -- `first_broken_position`; -- `expected_hash`; -- `actual_hash`; -- `source`; -- `path`. - -## Limits - -This is local tamper evidence. It can show that the current file no longer matches the hashes written into it. It is not remote signing, object lock, SIEM export, external timestamping, or immutable storage. diff --git a/docs/evidence-bundle.md b/docs/evidence-bundle.md deleted file mode 100644 index e0e2d43..0000000 --- a/docs/evidence-bundle.md +++ /dev/null @@ -1,26 +0,0 @@ -# Evidence bundle - -`examples/product-export` is a buyer-review fixture that demonstrates how the public code can verify product-shaped outputs. - -It contains: - -- `audit-log.jsonl` — local hash-chain audit export; -- `masked-query-result.json` — agent-facing result with masked PII columns; -- `risk-block-response.json` — destructive query blocked by risk attribution; -- `approval-response.json` — update query held for approval with risk attribution. - -Run: - -```bash -go run ./cmd/futrix-evidence-verify ./examples/product-export -``` - -The verifier checks: - -- audit hash-chain validity; -- masked columns use `masked:v1:` values; -- rows do not contain obvious raw email or phone values; -- the block response matches the public partial risk engine's `DELETE FROM users` decision; -- the approval response matches the public partial risk engine's `UPDATE ... WHERE ...` decision. - -These fixtures are sanitized. During a real Enterprise evaluation, ask FutrixData to export equivalent evidence from a disposable datasource in the evaluated environment, then run the same verifier or the narrower audit verifier against that export. diff --git a/docs/masking.md b/docs/masking.md deleted file mode 100644 index e3dc96b..0000000 --- a/docs/masking.md +++ /dev/null @@ -1,38 +0,0 @@ -# Masking specification - -## Default sensitivity levels - -FutrixData uses an extensible L1-L5 model by default. - -| Level | Meaning | Examples | -| --- | --- | --- | -| L1 Public | Non-sensitive operational data | `id`, `created_at`, `status` | -| L2 Internal | Internal identifiers and metadata | `user_id`, `session_id`, `request_id` | -| L3 Confidential | Indirect personal, behavior, or location data | `ip_address`, `user_agent`, `device_id` | -| L4 Sensitive | Direct PII, financial, or medical data | `email`, `phone`, `salary`, `date_of_birth` | -| L5 Critical | Credentials and high-sensitivity personal data | `password`, `credit_card`, `api_secret`, `home_address` | - -By default, agents can receive L1-L3 fields. L4, L5, unconfirmed, and unknown levels are masked. - -## Masking algorithm - -For each value: - -1. Start with a local root secret. -2. Derive a per-field key with HMAC-SHA256 over: - -```text -futrixdata:masking:v1 -datasource: -field: -``` - -3. HMAC-SHA256 the raw value string with the derived key. -4. Take the first 16 hex characters. -5. Return `masked:v1:<16 hex chars>`. - -The same value in the same datasource and field masks to the same output. The same value in a different field or datasource masks differently. - -## Limits - -Deterministic masking is not anonymization. It keeps equality useful for agents, but low-cardinality fields can still be guessed by enumeration. Treat masked values as pseudonymous data, not public data. diff --git a/docs/open-source-scope.md b/docs/open-source-scope.md deleted file mode 100644 index ed8bc94..0000000 --- a/docs/open-source-scope.md +++ /dev/null @@ -1,97 +0,0 @@ -# Open-source scope analysis - -## Goal - -Enterprise buyers need enough code to verify FutrixData's security claims before purchase. At the same time, the public repository should not contain enough product code to rebuild the full desktop app or Enterprise server. - -The best boundary is a public security package: specs, testable verifiers, protocol types, and portable rule/masking code. - -## Claims that should be reviewable - -The public FutrixData site and documentation emphasize these security claims: - -- agents get query results, not raw credentials; -- risky statements are checked before execution; -- sensitive fields can be masked before results reach agents; -- agent calls are attributed and audited; -- audit logs use a local hash-chain verifier; -- Enterprise users can reason about protocol behavior, approval behavior, and revocation/error surfaces. - -Those claims map cleanly to small public modules. - -## Recommended public modules - -### 1. Audit-chain verifier - -Open `pkg/auditchain` and `cmd/futrix-audit-verify`. - -This gives buyers something concrete to run against exported audit logs. It is useful without revealing the whole audit store, identity store, desktop UI, daemon, or Enterprise audit pipeline. - -### 2. Masking algorithm and sensitivity types - -Open `pkg/masking`. - -This exposes the deterministic HMAC-SHA256 masking contract, default L1-L5 level model, and row-level masking behavior. It does not expose credential storage, OS keyring integration, datasource adapters, UI workflows, or AI classification orchestration. - -### 3. Partial risk-engine core - -Open `pkg/riskengine`. - -This lets reviewers inspect the built-in rule model, lightweight statement parser, matching priority, and allow/warn/approval/block evaluator. It intentionally omits production datasource execution, richer parser integrations, EXPLAIN adapters, database clients, trust-mode storage, and daemon cache handling. - -### 4. Agent protocol types - -Open `pkg/protocol`. - -This documents tool names, request/response envelopes, approval-required responses, risk attribution, and masked-column reporting. It does not expose the real dispatcher, access-key store, IPC server, MCP server implementation, or Enterprise HTTP service. - -### 5. Release verification helper - -Open `release-verification/verify-checksums.sh`. - -This supports binary integrity checks without publishing signing credentials, private CI secrets, notarization keys, or release automation internals. - -### 6. Specs and examples - -Open `docs/` and `examples/`. - -Specs make security behavior easier to review. Examples let users test the packages quickly. - -### 7. Assurance evidence verifier - -Open `pkg/evidence` and `cmd/futrix-evidence-verify`. - -This gives buyers a single command that checks the public evidence bundle: audit-chain validity, masked agent results, blocked risk response, and approval-required response. It is not a substitute for a full product evaluation, but it turns the public repository into a runnable assurance package. - -## Keep private - -These areas should stay outside the public repository: - -- desktop app shell and UX; -- datasource adapters and connection logic; -- credential encryption and OS keyring integration; -- auth, license, account, billing, and entitlement code; -- Enterprise server, SSO, RBAC, tenant management, and deployment automation; -- release signing, notarization, certificates, CI secrets, and private packaging tokens; -- commercial support workflows and internal roadmap; -- customer data, telemetry, logs, or operational endpoints. - -## Why this boundary works - -The selected modules prove the most important security contracts: - -- what is audited; -- how local audit tampering is detected; -- how sensitive values are transformed before reaching agents; -- how rules decide allow, warn, approval, or block; -- what an agent sees when a call succeeds, fails, or needs approval. - -They do not include the product shell that wires everything into a complete commercial application. - -## Future phases - -Phase 1 should publish this package once reviewed. - -Phase 2 can add more test vectors and exported audit samples. - -Phase 3 can consider additional runtime components only when their APIs are stable and their release will not expose commercial implementation details. diff --git a/docs/production-consistency.md b/docs/production-consistency.md deleted file mode 100644 index db05d5d..0000000 --- a/docs/production-consistency.md +++ /dev/null @@ -1,62 +0,0 @@ -# Production consistency statement - -This repository contains public security code and specs extracted from FutrixData's production design. It is not a full source release of the product. - -## Directly aligned with production concepts - -These public packages track production concepts closely: - -| Public package | Production alignment | Commercial additions | -| --- | --- | --- | -| `pkg/auditchain` | Matches the local audit hash-chain field names, hash inputs, version string, and verifier result shape used by FutrixData audit exports. | Secure file storage, file locking, encrypted local data handling, product CLI integration, UI display, Enterprise audit aggregation. | -| `pkg/masking` | Matches the L1-L5 sensitivity model and `masked:v1:<16 hex>` deterministic HMAC-SHA256 output contract. | OS keyring secret management, migration fallback, SQL result-column origin tracking, datasource classification store, product UI flows. | -| `pkg/protocol` | Matches the public agent-facing concepts: tool names, tool-call envelope, approval response, error response, audit IDs, masked columns, and risk attribution. | Actual MCP server, Skill CLI, daemon IPC, HTTP/Enterprise transport, access-key validation, revocation, schema-egress gates. | - -## Portable subset - -`pkg/riskengine` is a portable public subset, not the complete commercial risk engine. - -It opens: - -- rule data model; -- lightweight statement parsing; -- built-in baseline rules; -- user rule priority and scope handling; -- allow / warn / require_approval / block evaluator. - -The commercial product additionally includes: - -- richer SQL parser integrations; -- live datasource adapters; -- EXPLAIN and query-plan probes; -- datasource trust-mode storage; -- approval routing; -- daemon rule-cache reload behavior; -- product audit writing; -- UI configuration and Enterprise policy management. - -## Public examples - -`examples/product-export` contains sanitized product-export fixtures. They use safe demo values and product-shaped JSON contracts. They are not copied from a real customer's data or from a developer's private local database. - -The purpose is to let reviewers run the public verifier against the same kinds of outputs a product evaluation should request: - -- an audit log export; -- an agent-facing masked query result; -- a risk-block response; -- an approval-required response with risk attribution. - -## Wording guidance - -Accurate public wording: - -- "FutrixData publishes security specifications, verifiers, protocol types, masking code, and a partial risk-engine core." -- "The audit-chain verifier can check product-exported audit logs." -- "The public risk engine is a portable subset; the commercial product adds live execution, EXPLAIN probes, trust modes, approval routing, and Enterprise policy controls." - -Avoid: - -- "FutrixData is fully open source." -- "The entire risk engine is open source." -- "Public masked fixtures prove customer data is anonymized." -- "Local audit hash chains are immutable." diff --git a/docs/risk-engine.md b/docs/risk-engine.md deleted file mode 100644 index 2988b24..0000000 --- a/docs/risk-engine.md +++ /dev/null @@ -1,57 +0,0 @@ -# Partial risk-engine specification - -This repository opens the portable core of the FutrixData risk engine: rule types, a lightweight statement parser, rule matching, priority ordering, and the final allow/warn/approval/block evaluator. - -It is intentionally not the full commercial runtime. The desktop and Enterprise products add datasource adapters, richer SQL parser integrations, EXPLAIN probes, trust-mode storage, approval dispatch, daemon cache behavior, and audit writing. - -## Decisions - -The rule engine returns one of four actions: - -| Action | Meaning | -| --- | --- | -| `allow` | The statement can proceed. | -| `warn` | The statement is risky enough to surface to policy. | -| `require_approval` | The statement must be explicitly approved. | -| `block` | The statement should not run through an agent path. | - -Risk levels are derived from actions: - -- `allow` -> `low`; -- `warn` -> `medium`; -- `require_approval` and `block` -> `high`. - -## Rule shape - -```json -{ - "id": "sql-block-delete-no-where", - "code": "SQL-005", - "description": "Block DELETE without WHERE", - "scope": { - "dsTypes": ["mysql", "postgresql", "d1"] - }, - "enabled": true, - "priority": 90, - "action": "block", - "reason": "DELETE without WHERE", - "when": { - "command": ["delete"], - "hasWhere": false - } -} -``` - -User rules are evaluated before built-in rules. More specific scope and higher priority win. - -## Open risk-engine coverage - -The public package includes portable built-ins and evaluator logic for: - -- SQL-family sources: MySQL, PostgreSQL, Cloudflare D1; -- Redis and Redis Cluster; -- MongoDB; -- Elasticsearch; -- DynamoDB PartiQL. - -The commercial product adds live datasource execution, richer SQL parsing, EXPLAIN probes, trust-mode storage, approval routing, and runtime cache behavior. diff --git a/docs/threat-model.md b/docs/threat-model.md deleted file mode 100644 index cef5218..0000000 --- a/docs/threat-model.md +++ /dev/null @@ -1,44 +0,0 @@ -# Threat model - -## Protected assets - -FutrixData is designed to reduce risk around: - -- database credentials; -- sensitive row values returned to AI agents; -- destructive or expensive database statements; -- schema metadata sent to AI tooling; -- agent identity and access-key misuse; -- local audit-log tampering after records are written. - -## Trusted components - -The local desktop installation or Enterprise deployment is trusted to enforce policy before database execution. - -The configured database is trusted to execute accepted statements and return truthful results. - -The local secret store is trusted to protect the masking root secret. - -The person approving a held action is trusted to understand the displayed summary. - -## Untrusted or partially trusted components - -AI agents are treated as partially trusted. They can request actions but should not receive database credentials or bypass policy. - -LLM providers are treated as external processors. Sensitive row values should be masked before they reach an agent context when policy requires masking. - -Local files are not immutable. The audit hash chain can detect changes to the current chained section, but it cannot stop a fully privileged local attacker from rewriting all rows and recomputing hashes. - -## Out of scope - -This package does not prove: - -- endpoint hardening of the commercial Enterprise server; -- correctness of every datasource adapter; -- protection against a compromised operating system; -- remote audit anchoring; -- signing-key custody; -- billing or license enforcement; -- SSO or RBAC implementation details. - -Those areas remain part of the commercial product review. diff --git a/examples/audit-log/valid.jsonl b/examples/audit-log/valid.jsonl deleted file mode 100644 index 551c07a..0000000 --- a/examples/audit-log/valid.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"id":"legacy","toolName":"list_datasources","status":"success"} -{"chain_hash":"315274addcb6d90c3cad80e700c2a5ec64088bf08346127fa801744d28a466de","chain_version":"local-sha256-v1","id":"audit-1","payload_hash":"69786a037ca8fe364aa96e150211e53801dd4c54b13ca77535f7f0a1eb026b09","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","seq":2,"status":"success","toolName":"execute_statement"} -{"chain_hash":"630a1544e8a5bfaa70f80bed9af05f3344c91414604aebf85f4d63145a449bd2","chain_version":"local-sha256-v1","id":"audit-2","payload_hash":"001ee09721bbc8c99ed9e78f202ee52f2b25cc3bb8fd7578b3ceeff895d01dc0","prev_hash":"315274addcb6d90c3cad80e700c2a5ec64088bf08346127fa801744d28a466de","seq":3,"status":"approval_required","toolName":"execute_statement"} diff --git a/examples/product-export/README.md b/examples/product-export/README.md deleted file mode 100644 index afe4531..0000000 --- a/examples/product-export/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Product export evidence bundle - -This directory contains a sanitized FutrixData product-export evidence bundle. - -The shapes match the product contracts used by the desktop and Enterprise agent paths: - -- `audit-log.jsonl` is a chained agent audit export. -- `masked-query-result.json` shows an agent-facing query result after PII masking. -- `risk-block-response.json` shows a blocked destructive statement. -- `approval-response.json` shows a statement held for approval with risk attribution. - -The values are safe demo values, not a copy of a user's local database or audit history. This is intentional: public fixtures should prove the contract without publishing private customer or developer data. - -Verify the whole bundle: - -```bash -go run ./cmd/futrix-evidence-verify ./examples/product-export -``` diff --git a/examples/product-export/approval-response.json b/examples/product-export/approval-response.json deleted file mode 100644 index f2c34b7..0000000 --- a/examples/product-export/approval-response.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "tool": "execute_statement", - "ok": false, - "approvalRequired": { - "tool": "execute_statement", - "summary": "Execute statement on datasource \"prod-postgres\"", - "params": { - "datasourceId": "prod-postgres", - "statement": "UPDATE users SET status = 'inactive' WHERE id = 1042" - }, - "riskAttribution": { - "source": "risk_engine", - "action": "warn", - "level": "medium", - "ruleId": "sql-warn-update", - "ruleCode": "SQL-008", - "ruleDescription": "Warn on UPDATE with WHERE", - "builtin": true, - "reasons": ["UPDATE"] - } - }, - "auditId": "audit_public_approval_001" -} diff --git a/examples/product-export/audit-log.jsonl b/examples/product-export/audit-log.jsonl deleted file mode 100644 index 551c07a..0000000 --- a/examples/product-export/audit-log.jsonl +++ /dev/null @@ -1,3 +0,0 @@ -{"id":"legacy","toolName":"list_datasources","status":"success"} -{"chain_hash":"315274addcb6d90c3cad80e700c2a5ec64088bf08346127fa801744d28a466de","chain_version":"local-sha256-v1","id":"audit-1","payload_hash":"69786a037ca8fe364aa96e150211e53801dd4c54b13ca77535f7f0a1eb026b09","prev_hash":"0000000000000000000000000000000000000000000000000000000000000000","seq":2,"status":"success","toolName":"execute_statement"} -{"chain_hash":"630a1544e8a5bfaa70f80bed9af05f3344c91414604aebf85f4d63145a449bd2","chain_version":"local-sha256-v1","id":"audit-2","payload_hash":"001ee09721bbc8c99ed9e78f202ee52f2b25cc3bb8fd7578b3ceeff895d01dc0","prev_hash":"315274addcb6d90c3cad80e700c2a5ec64088bf08346127fa801744d28a466de","seq":3,"status":"approval_required","toolName":"execute_statement"} diff --git a/examples/product-export/masked-query-result.json b/examples/product-export/masked-query-result.json deleted file mode 100644 index 8887855..0000000 --- a/examples/product-export/masked-query-result.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "tool": "execute_statement", - "ok": true, - "datasourceId": "prod-postgres", - "entity": "users", - "maskedColumns": ["email", "phone"], - "rows": [ - { - "id": 1042, - "email": "masked:v1:8f3a1c9b72e04d11", - "phone": "masked:v1:b219ac74e1d908f2", - "status": "active" - } - ] -} diff --git a/examples/product-export/risk-block-response.json b/examples/product-export/risk-block-response.json deleted file mode 100644 index 7f7bab0..0000000 --- a/examples/product-export/risk-block-response.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "tool": "execute_statement", - "ok": false, - "error": { - "code": "tool_error", - "message": "DELETE without WHERE", - "riskAttribution": { - "source": "risk_engine", - "action": "block", - "level": "high", - "ruleId": "sql-block-delete-no-where", - "ruleCode": "SQL-005", - "ruleDescription": "Block DELETE without WHERE", - "builtin": true, - "reasons": ["DELETE without WHERE"] - } - }, - "auditId": "audit_public_block_001" -} diff --git a/examples/risk-rules/sql-basic.json b/examples/risk-rules/sql-basic.json deleted file mode 100644 index 3398c17..0000000 --- a/examples/risk-rules/sql-basic.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "id": "prod-users-delete-approval", - "description": "Require approval before deleting from users in production", - "scope": { - "datasourceId": "prod-postgres", - "entity": "users" - }, - "enabled": true, - "priority": 500, - "action": "require_approval", - "reason": "production user data", - "when": { - "command": ["delete"] - } - } -] diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..903d0b9 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..aef91b4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + + FutrixData Platform + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..344fbbf --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,10374 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "@tanstack/vue-virtual": "^3.13.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.2.7", + "echarts": "^5.6.0", + "lossless-json": "^4.3.0", + "lucide-vue-next": "^0.526.0", + "marked": "^15.0.6", + "monaco-editor": "^0.55.1", + "pinia": "^2.2.6", + "protobufjs": "^8.0.0", + "reka-ui": "^2.4.0", + "sql-formatter": "^15.7.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "three": "^0.173.0", + "vaul-vue": "^0.4.1", + "vega": "^6.2.0", + "vega-embed": "^7.1.0", + "vega-lite": "^6.4.2", + "vue": "^3.5.17", + "vue-router": "^4.5.1", + "vue-sonner": "^2.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.11", + "@types/node": "^24.0.15", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.21", + "jsdom": "^25.0.1", + "postcss": "^8.5.6", + "shadcn-vue": "^2.2.0", + "tailwindcss": "^4.1.11", + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.5.tgz", + "integrity": "sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.5.tgz", + "integrity": "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.4.tgz", + "integrity": "sha512-AoziS8lRQ3ew/lY5J4JSlzYSN9Fo0oiyMBY37L3Bwq4mOQJT5GSrdZYLFPt6pH1LApDI3ZJceNyx+rHRACZSeQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.5.tgz", + "integrity": "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.9.tgz", + "integrity": "sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/utils": "^0.2.10", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@internationalized/date": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", + "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.5.tgz", + "integrity": "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/colors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", + "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", + "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.19.tgz", + "integrity": "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.19", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.19.tgz", + "integrity": "sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.28.1.tgz", + "integrity": "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@unovue/detypes": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@unovue/detypes/-/detypes-0.8.5.tgz", + "integrity": "sha512-Yz4JeWOHGa+w/3YudVdng8hgN/VGW9cvp8xmFkmPPFzalGblLPPSpIRiwVo853yLstMZO2LLwe0vOoLAQsUQXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.5", + "@babel/preset-typescript": "^7.24.1", + "@vue/compiler-dom": "^3.4.27", + "@vue/compiler-sfc": "^3.4.27", + "@vuedx/template-ast-types": "0.7.1", + "fast-glob": "^3.3.2", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "bin": { + "detypes": "detype.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", + "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.26", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", + "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", + "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.26", + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", + "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", + "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", + "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/shared": "3.5.26" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", + "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.26", + "@vue/runtime-core": "3.5.26", + "@vue/shared": "3.5.26", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", + "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "vue": "3.5.26" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", + "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vuedx/template-ast-types": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@vuedx/template-ast-types/-/template-ast-types-0.7.1.tgz", + "integrity": "sha512-Mqugk/F0lFN2u9bhimH6G1kSu2hhLi2WoqgCVxrMvgxm2kDc30DtdvVGRq+UgEmKVP61OudcMtZqkUoGQeFBUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "^3.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/znck" + } + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "name": "ast-types-x", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/ast-types-x/-/ast-types-x-1.18.0.tgz", + "integrity": "sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/ast-types-x/-/ast-types-x-1.18.0.tgz", + "integrity": "sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz", + "integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz", + "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^17.2.3", + "exsolve": "^1.0.8", + "giget": "^2.0.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.0.0", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001763", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz", + "integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-progress": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz", + "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-progress/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-progress/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.3.0.tgz", + "integrity": "sha512-/+40ljC3ONVnYIttjMWrlL51nItDAbBrq2upN8BPyvGU/2n5Oxw3tbNwORCaNuNqLJnxGqOfjUuhsv7l5Q4IsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", + "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "source-map": "^0.6.1", + "source-map-resolve": "^0.6.0" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-dsv/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/eciesjs": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.16.tgz", + "integrity": "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.4", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", + "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-timeout": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-1.0.2.tgz", + "integrity": "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gonzales-pe": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/gonzales-pe/-/gonzales-pe-4.3.0.tgz", + "integrity": "sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "gonzales": "bin/gonzales.js" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/identifier-regex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/identifier-regex/-/identifier-regex-1.0.1.tgz", + "integrity": "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-identifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-identifier/-/is-identifier-1.0.1.tgz", + "integrity": "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "identifier-regex": "^1.0.0", + "super-regex": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.sortedlastindex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.sortedlastindex/-/lodash.sortedlastindex-4.1.0.tgz", + "integrity": "sha512-s8xEQdsp2Tu5zUqVdFSe9C0kR8YlnAJYLqMdkh+pIRBRxF6/apWseLdHl3/+jv2I61dhPwtI/Ff+EqvCpc+N8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lossless-json": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", + "integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-vue-next": { + "version": "0.526.0", + "resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.526.0.tgz", + "integrity": "sha512-YM3i5f56+401tjRP2LMFIG7gtBRd55tom7P9Fl2HfIlYI6+lwl37RO15RDPK4mMHFiFWU0RsVyVQCBnaUdVX8A==", + "license": "ISC", + "peerDependencies": { + "vue": ">=3.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-asynchronous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", + "integrity": "sha512-T9BPOmEOhp6SmV25SwLVcHK4E6JyG/coH3C6F1NjNXSziv/fd4GmsqMk8YR6qpPOswfaOCApSNkZv6fxoaYFcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-event": "^6.0.0", + "type-fest": "^4.6.0", + "web-worker": "1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-html-parser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.2.tgz", + "integrity": "sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", + "integrity": "sha512-m0pg2zscbYgWbqRR6ABga5c3sZdEon7bSgjnlXC64kxtxLOyjRcbbUkLj7HFyy/FTD+P2xdBWu8snGhYI0jc4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.2.2", + "string-width": "^8.1.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-event": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/p-event/-/p-event-6.0.1.tgz", + "integrity": "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-less": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-less/-/postcss-less-6.0.0.tgz", + "integrity": "sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "postcss": "^8.3.5" + } + }, + "node_modules/postcss-sass": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/postcss-sass/-/postcss-sass-0.5.0.tgz", + "integrity": "sha512-qtu8awh1NMF3o9j/x9j3EZnd+BlF66X6NZYl12BdKoG2Z4hmydOt/dZj2Nq+g0kfk2pQy3jeYFBmvG9DBwynGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "gonzales-pe": "^4.3.0", + "postcss": "^8.2.14" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-styl": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/postcss-styl/-/postcss-styl-0.12.3.tgz", + "integrity": "sha512-8I7Cd8sxiEITIp32xBK4K/Aj1ukX6vuWnx8oY/oAH35NfQI4OZaY5nd68Yx8HeN5S49uhQ6DL0rNk0ZBu/TaLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-diff": "^1.2.0", + "lodash.sortedlastindex": "^4.1.0", + "postcss": "^7.0.27 || ^8.0.0", + "stylus": "^0.57.0" + }, + "engines": { + "node": "^8.10.0 || ^10.13.0 || ^11.10.1 || >=12.13.0" + }, + "funding": { + "url": "https://opencollective.com/stylus" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/protobufjs": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", + "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recast-x": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/recast-x/-/recast-x-1.0.5.tgz", + "integrity": "sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "npm:ast-types-x@1.18.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reka-ui": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.7.0.tgz", + "integrity": "sha512-m+XmxQN2xtFzBP3OAdIafKq7C8OETo2fqfxcIIxYmNN2Ch3r5oAf6yEYCIJg5tL/yJU2mHqF70dCCekUkrAnXA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.13", + "@floating-ui/vue": "^1.1.6", + "@internationalized/date": "^3.5.0", + "@internationalized/number": "^3.5.0", + "@tanstack/vue-virtual": "^3.12.0", + "@vueuse/core": "^12.5.0", + "@vueuse/shared": "^12.5.0", + "aria-hidden": "^1.2.4", + "defu": "^6.1.4", + "ohash": "^2.0.11" + }, + "peerDependencies": { + "vue": ">= 3.2.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true, + "license": "ISC" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shadcn-vue": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/shadcn-vue/-/shadcn-vue-2.4.3.tgz", + "integrity": "sha512-usi/YjtKyc7wMUt6rk9LspolfNU2O+/X6FCmb+xrYT7kiJ02UPpNTVyAW5gkATm9ZCu7VwOZzklo76XwMArKEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dotenvx/dotenvx": "^1.51.1", + "@modelcontextprotocol/sdk": "^1.24.3", + "@unovue/detypes": "^0.8.5", + "@vue/compiler-sfc": "^3.5", + "c12": "^3.3.2", + "commander": "^14.0.2", + "consola": "^3.4.2", + "dedent": "^1.7.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "fs-extra": "^11.3.2", + "fuzzysort": "^3.1.0", + "get-tsconfig": "^4.13.0", + "magic-string": "^0.30.21", + "nypm": "^0.6.2", + "ofetch": "^1.5.1", + "ora": "^9.0.0", + "pathe": "^2.0.3", + "postcss": "^8.5.6", + "prompts": "^2.4.2", + "reka-ui": "^2.6.1", + "semver": "^7.7.3", + "stringify-object": "^6.0.0", + "tailwindcss": "^4.1.17", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "ts-morph": "^27.0.2", + "undici": "^7.16.0", + "vue-metamorph": "^3.3.3", + "zod": "^3.25.76", + "zod-to-json-schema": "^3.25.0" + }, + "bin": { + "shadcn-vue": "dist/index.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "license": "MIT", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + }, + "node_modules/sql-formatter": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.7.0.tgz", + "integrity": "sha512-o2yiy7fYXK1HvzA8P6wwj8QSuwG3e/XcpWht/jIxkQX99c0SVPw0OXdLSV9fHASPiYB09HLA0uq8hokGydi/QA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-object": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-6.0.0.tgz", + "integrity": "sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-identifier": "^1.0.1", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylus": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/stylus/-/stylus-0.57.0.tgz", + "integrity": "sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css": "^3.0.0", + "debug": "^4.3.2", + "glob": "^7.1.6", + "safer-buffer": "^2.1.2", + "sax": "~1.2.4", + "source-map": "^0.7.3" + }, + "bin": { + "stylus": "bin/stylus" + }, + "engines": { + "node": "*" + } + }, + "node_modules/stylus/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stylus/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/stylus/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/super-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-1.1.0.tgz", + "integrity": "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-timeout": "^1.0.1", + "make-asynchronous": "^1.0.1", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.173.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.173.0.tgz", + "integrity": "sha512-AUwVmViIEUgBwxJJ7stnF0NkPpZxx1aZ6WiAbQ/Qq61h6I9UR4grXtZDmO8mnlaNORhHnIBlXJ1uBxILEKuVyw==", + "license": "MIT" + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/topojson-client/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-morph": { + "version": "27.0.2", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-27.0.2.tgz", + "integrity": "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.28.1", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", + "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul-vue": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vaul-vue/-/vaul-vue-0.4.1.tgz", + "integrity": "sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==", + "dependencies": { + "@vueuse/core": "^10.8.0", + "reka-ui": "^2.0.0", + "vue": "^3.4.5" + }, + "peerDependencies": { + "reka-ui": "^2.0.0", + "vue": "^3.3.0" + } + }, + "node_modules/vaul-vue/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/vaul-vue/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vaul-vue/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vega": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/vega/-/vega-6.2.0.tgz", + "integrity": "sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-crossfilter": "~5.1.0", + "vega-dataflow": "~6.1.0", + "vega-encode": "~5.1.0", + "vega-event-selector": "~4.0.0", + "vega-expression": "~6.1.0", + "vega-force": "~5.1.0", + "vega-format": "~2.1.0", + "vega-functions": "~6.1.0", + "vega-geo": "~5.1.0", + "vega-hierarchy": "~5.1.0", + "vega-label": "~2.1.0", + "vega-loader": "~5.1.0", + "vega-parser": "~7.1.0", + "vega-projection": "~2.1.0", + "vega-regression": "~2.1.0", + "vega-runtime": "~7.1.0", + "vega-scale": "~8.1.0", + "vega-scenegraph": "~5.1.0", + "vega-statistics": "~2.0.0", + "vega-time": "~3.1.0", + "vega-transforms": "~5.1.0", + "vega-typings": "~2.1.0", + "vega-util": "~2.1.0", + "vega-view": "~6.1.0", + "vega-view-transforms": "~5.1.0", + "vega-voronoi": "~5.1.0", + "vega-wordcloud": "~5.1.0" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + } + }, + "node_modules/vega-canvas": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-2.0.0.tgz", + "integrity": "sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==", + "license": "BSD-3-Clause" + }, + "node_modules/vega-crossfilter": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-5.1.0.tgz", + "integrity": "sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-dataflow": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-6.1.0.tgz", + "integrity": "sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-format": "^2.1.0", + "vega-loader": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-embed": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/vega-embed/-/vega-embed-7.1.0.tgz", + "integrity": "sha512-ZmEIn5XJrQt7fSh2lwtSdXG/9uf3yIqZnvXFEwBJRppiBgrEWZcZbj6VK3xn8sNTFQ+sQDXW5sl/6kmbAW3s5A==", + "license": "BSD-3-Clause", + "dependencies": { + "fast-json-patch": "^3.1.1", + "json-stringify-pretty-compact": "^4.0.0", + "semver": "^7.7.2", + "tslib": "^2.8.1", + "vega-interpreter": "^2.0.0", + "vega-schema-url-parser": "^3.0.2", + "vega-themes": "3.0.0", + "vega-tooltip": "1.0.0" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + }, + "peerDependencies": { + "vega": "*", + "vega-lite": "*" + } + }, + "node_modules/vega-encode": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-5.1.0.tgz", + "integrity": "sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-interpolate": "^3.0.1", + "vega-dataflow": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-event-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-4.0.0.tgz", + "integrity": "sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==", + "license": "BSD-3-Clause" + }, + "node_modules/vega-expression": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-6.1.0.tgz", + "integrity": "sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/estree": "^1.0.8", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-force": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-force/-/vega-force-5.1.0.tgz", + "integrity": "sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-force": "^3.0.0", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-format/-/vega-format-2.1.0.tgz", + "integrity": "sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-format": "^3.1.0", + "d3-time-format": "^4.1.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-functions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-6.1.1.tgz", + "integrity": "sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-geo": "^3.1.1", + "vega-dataflow": "^6.1.0", + "vega-expression": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-scenegraph": "^5.1.0", + "vega-selections": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-geo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-5.1.0.tgz", + "integrity": "sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-geo": "^3.1.1", + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-projection": "^2.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-hierarchy": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-5.1.0.tgz", + "integrity": "sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-hierarchy": "^3.1.2", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-interpreter": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vega-interpreter/-/vega-interpreter-2.2.1.tgz", + "integrity": "sha512-o+4ZEme2mdFLewlpF76dwPWW2VkZ3TAF3DMcq75/NzA5KPvnN4wnlCM8At2FVawbaHRyGdVkJSS5ROF5KwpHPQ==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-label/-/vega-label-2.1.0.tgz", + "integrity": "sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-lite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-6.4.2.tgz", + "integrity": "sha512-Mv2PaRIpijz256LM0NdOJd9Md8cqyrXina54xW6Qp865YfY502zlXGUst+W/XznVwISGfatt0yLZuDqCUbBDuw==", + "license": "BSD-3-Clause", + "dependencies": { + "json-stringify-pretty-compact": "~4.0.0", + "tslib": "~2.8.1", + "vega-event-selector": "~4.0.0", + "vega-expression": "~6.1.0", + "vega-util": "~2.1.0", + "yargs": "~18.0.0" + }, + "bin": { + "vl2pdf": "bin/vl2pdf", + "vl2png": "bin/vl2png", + "vl2svg": "bin/vl2svg", + "vl2vg": "bin/vl2vg" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + }, + "peerDependencies": { + "vega": "^6.0.0" + } + }, + "node_modules/vega-loader": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-5.1.0.tgz", + "integrity": "sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dsv": "^3.0.1", + "topojson-client": "^3.1.0", + "vega-format": "^2.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-7.1.0.tgz", + "integrity": "sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-event-selector": "^4.0.0", + "vega-functions": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-projection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-2.1.0.tgz", + "integrity": "sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-geo": "^3.1.1", + "d3-geo-projection": "^4.0.0", + "vega-scale": "^8.1.0" + } + }, + "node_modules/vega-regression": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-2.1.0.tgz", + "integrity": "sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-runtime": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-7.1.0.tgz", + "integrity": "sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-scale": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-8.1.0.tgz", + "integrity": "sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-scenegraph": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-5.1.0.tgz", + "integrity": "sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "^3.1.0", + "d3-shape": "^3.2.0", + "vega-canvas": "^2.0.0", + "vega-loader": "^5.1.0", + "vega-scale": "^8.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-schema-url-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vega-schema-url-parser/-/vega-schema-url-parser-3.0.2.tgz", + "integrity": "sha512-xAnR7KAvNPYewI3O0l5QGdT8Tv0+GCZQjqfP39cW/hbe/b3aYMAQ39vm8O2wfXUHzm04xTe7nolcsx8WQNVLRQ==", + "license": "BSD-3-Clause" + }, + "node_modules/vega-selections": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-6.1.2.tgz", + "integrity": "sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "3.2.4", + "vega-expression": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-statistics": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-2.0.0.tgz", + "integrity": "sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4" + } + }, + "node_modules/vega-themes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vega-themes/-/vega-themes-3.0.0.tgz", + "integrity": "sha512-1iFiI3BNmW9FrsLnDLx0ZKEddsCitRY3XmUAwp6qmp+p+IXyJYc9pfjlVj9E6KXBPfm4cQyU++s0smKNiWzO4g==", + "license": "BSD-3-Clause", + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + }, + "peerDependencies": { + "vega": "*", + "vega-lite": "*" + } + }, + "node_modules/vega-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vega-time/-/vega-time-3.1.0.tgz", + "integrity": "sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-tooltip": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vega-tooltip/-/vega-tooltip-1.0.0.tgz", + "integrity": "sha512-P1R0JP29v0qnTuwzCQ0SPJlkjAzr6qeyj+H4VgUFSykHmHc1OBxda//XBaFDl/bZgIscEMvjKSjZpXd84x3aZQ==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-util": "^2.0.0" + }, + "funding": { + "url": "https://app.hubspot.com/payments/GyPC972GD9Rt" + } + }, + "node_modules/vega-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-5.1.0.tgz", + "integrity": "sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "vega-dataflow": "^6.1.0", + "vega-statistics": "^2.0.0", + "vega-time": "^3.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-typings": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-2.1.0.tgz", + "integrity": "sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/geojson": "7946.0.16", + "vega-event-selector": "^4.0.0", + "vega-expression": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-util": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vega-util/-/vega-util-2.1.0.tgz", + "integrity": "sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==", + "license": "BSD-3-Clause" + }, + "node_modules/vega-view": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-6.1.0.tgz", + "integrity": "sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^3.2.4", + "d3-timer": "^3.0.1", + "vega-dataflow": "^6.1.0", + "vega-format": "^2.1.0", + "vega-functions": "^6.1.0", + "vega-runtime": "^7.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-view-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-5.1.0.tgz", + "integrity": "sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-dataflow": "^6.1.0", + "vega-scenegraph": "^5.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-voronoi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-5.1.0.tgz", + "integrity": "sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-delaunay": "^6.0.4", + "vega-dataflow": "^6.1.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vega-wordcloud": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-5.1.0.tgz", + "integrity": "sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==", + "license": "BSD-3-Clause", + "dependencies": { + "vega-canvas": "^2.0.0", + "vega-dataflow": "^6.1.0", + "vega-scale": "^8.1.0", + "vega-statistics": "^2.0.0", + "vega-util": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.26", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", + "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.26", + "@vue/compiler-sfc": "3.5.26", + "@vue/runtime-dom": "3.5.26", + "@vue/server-renderer": "3.5.26", + "@vue/shared": "3.5.26" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz", + "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/vue-metamorph": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/vue-metamorph/-/vue-metamorph-3.3.3.tgz", + "integrity": "sha512-NPeDg2/ZL4lDJsC/PjEfFq+Ln3Rr7cX86AQo4bI5V6ziUTZOMY93HyIwKHW37BrIf4YB97llrGtvM+eMhwr1jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "8.0.0-alpha.12", + "ast-types-x": "1.18.0", + "chalk": "^5.3.0", + "cli-progress": "^3.12.0", + "commander": "^13.1.0", + "deep-diff": "^1.0.2", + "fs-extra": "^11.2.0", + "glob": "^11.0.0", + "lodash-es": "^4.17.21", + "magic-string": "^0.30.10", + "micromatch": "^4.0.8", + "node-html-parser": "^7.0.1", + "postcss": "^8.4.38", + "postcss-less": "^6.0.0", + "postcss-sass": "^0.5.0", + "postcss-scss": "^4.0.9", + "postcss-styl": "^0.12.3", + "recast-x": "1.0.5", + "table": "^6.8.2", + "vue-eslint-parser": "^10.1.0" + }, + "bin": { + "vue-metamorph": "scripts/scaffold.js" + } + }, + "node_modules/vue-metamorph/node_modules/@babel/parser": { + "version": "8.0.0-alpha.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-alpha.12.tgz", + "integrity": "sha512-AzWmrp4uJ+DcXVH0uoUpJVhRqxNirC0BbXsZ82AQuVod41CoaV5G+cwcvtYusrIIxv7BIJb6ce0dQ9L0wAl1iA==", + "dev": true, + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^18.20.0 || ^20.10.0 || >=21.0.0" + } + }, + "node_modules/vue-metamorph/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", + "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-worker": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", + "integrity": "sha512-PgF341avzqyx60neE9DD+XS26MMNMoUQRz9NOZwW32nPQrF6p77f1htcnjBSEV8BGMKZ16choqUG4hyI0Hx7mA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..575fce0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,52 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "@radix-ui/colors": "^3.0.0", + "@tanstack/vue-virtual": "^3.13.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.2.7", + "echarts": "^5.6.0", + "lossless-json": "^4.3.0", + "lucide-vue-next": "^0.526.0", + "marked": "^15.0.6", + "monaco-editor": "^0.55.1", + "pinia": "^2.2.6", + "protobufjs": "^8.0.0", + "reka-ui": "^2.4.0", + "sql-formatter": "^15.7.0", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "three": "^0.173.0", + "vaul-vue": "^0.4.1", + "vega": "^6.2.0", + "vega-embed": "^7.1.0", + "vega-lite": "^6.4.2", + "vue": "^3.5.17", + "vue-router": "^4.5.1", + "vue-sonner": "^2.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.11", + "@types/node": "^24.0.15", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.21", + "jsdom": "^25.0.1", + "postcss": "^8.5.6", + "shadcn-vue": "^2.2.0", + "tailwindcss": "^4.1.11", + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vitest": "^3.2.4" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..c785f5f --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,6358 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/colors': + specifier: ^3.0.0 + version: 3.0.0 + '@tanstack/vue-virtual': + specifier: ^3.13.19 + version: 3.13.19(vue@3.5.29(typescript@5.9.3)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dompurify: + specifier: ^3.2.7 + version: 3.3.1 + echarts: + specifier: ^5.6.0 + version: 5.6.0 + lucide-vue-next: + specifier: ^0.526.0 + version: 0.526.0(vue@3.5.29(typescript@5.9.3)) + marked: + specifier: ^15.0.6 + version: 15.0.12 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + pinia: + specifier: ^2.2.6 + version: 2.3.1(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)) + protobufjs: + specifier: ^8.0.0 + version: 8.0.0 + reka-ui: + specifier: ^2.4.0 + version: 2.8.2(vue@3.5.29(typescript@5.9.3)) + sql-formatter: + specifier: ^15.7.0 + version: 15.7.2 + tailwind-merge: + specifier: ^3.3.1 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.2.1) + three: + specifier: ^0.173.0 + version: 0.173.0 + vaul-vue: + specifier: ^0.4.1 + version: 0.4.1(reka-ui@2.8.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) + vega: + specifier: ^6.2.0 + version: 6.2.0 + vega-embed: + specifier: ^7.1.0 + version: 7.1.0(vega-lite@6.4.2(vega@6.2.0))(vega@6.2.0) + vega-lite: + specifier: ^6.4.2 + version: 6.4.2(vega@6.2.0) + vue: + specifier: ^3.5.17 + version: 3.5.29(typescript@5.9.3) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.29(typescript@5.9.3)) + vue-sonner: + specifier: ^2.0.2 + version: 2.0.9 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.11 + version: 4.2.1(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0)) + '@types/node': + specifier: ^24.0.15 + version: 24.10.15 + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0))(vue@3.5.29(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.27(postcss@8.5.6) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + shadcn-vue: + specifier: ^2.2.0 + version: 2.4.3(eslint@10.0.2(jiti@2.6.1))(vue@3.5.29(typescript@5.9.3)) + tailwindcss: + specifier: ^4.1.11 + version: 4.2.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^7.0.5 + version: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(stylus@0.57.0) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.0-alpha.12': + resolution: {integrity: sha512-AzWmrp4uJ+DcXVH0uoUpJVhRqxNirC0BbXsZ82AQuVod41CoaV5G+cwcvtYusrIIxv7BIJb6ce0dQ9L0wAl1iA==} + engines: {node: ^18.20.0 || ^20.10.0 || >=21.0.0} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.2': + resolution: {integrity: sha512-YF+fE6LV4v5MGWRGj7G404/OZzGNepVF8fxk7jqmqo3lrza7a0uUcDnROGRBG1WFC1omYUS/Wp1f42i0M+3Q3A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.2': + resolution: {integrity: sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.0': + resolution: {integrity: sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.2': + resolution: {integrity: sha512-HOy56KJt48Bx8KmJ+XGQNSUMT/6dZee/M54XyUyuvTvPXJmsERRvBchsUVx1UMe1WwIH49XLAczNC7V2INsuUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.0': + resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@floating-ui/vue@1.1.10': + resolution: {integrity: sha512-vdf8f6rHnFPPLRsmL4p12wYl+Ux4mOJOkjzKEMYVnwdf7UFdvBtHlLvQyx8iKG5vhPRbDRgZxdtpmyigDPjzYg==} + + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@internationalized/date@3.11.0': + resolution: {integrity: sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q==} + + '@internationalized/number@3.6.5': + resolution: {integrity: sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@radix-ui/colors@3.0.0': + resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/virtual-core@3.13.19': + resolution: {integrity: sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g==} + + '@tanstack/vue-virtual@3.13.19': + resolution: {integrity: sha512-07Fp1TYuIziB4zIDA/moeDKHODePy3K1fN4c4VIAGnkxo1+uOvBJP7m54CoxKiQX6Q9a1dZnznrwOg9C86yvvA==} + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.10.15': + resolution: {integrity: sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@unovue/detypes@0.8.5': + resolution: {integrity: sha512-Yz4JeWOHGa+w/3YudVdng8hgN/VGW9cvp8xmFkmPPFzalGblLPPSpIRiwVo853yLstMZO2LLwe0vOoLAQsUQXw==} + engines: {node: '>=18'} + hasBin: true + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} + + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} + + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} + + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} + peerDependencies: + vue: 3.5.29 + + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + + '@vue/test-utils@2.4.6': + resolution: {integrity: sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==} + + '@vuedx/template-ast-types@0.7.1': + resolution: {integrity: sha512-Mqugk/F0lFN2u9bhimH6G1kSu2hhLi2WoqgCVxrMvgxm2kDc30DtdvVGRq+UgEmKVP61OudcMtZqkUoGQeFBUQ==} + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/core@14.2.1': + resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/metadata@14.2.1': + resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + '@vueuse/shared@14.2.1': + resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==} + peerDependencies: + vue: ^3.5.0 + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-x@1.18.0: + resolution: {integrity: sha512-ZtfIlyTCmnAXPCQo4mSDtFsHL7L3q0sJfpVYPmy5uYPjs+fynzOuc1Cg6yQ9fF6h61RjEWtOlRFwV1Kc80Qs6A==} + engines: {node: '>=4'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atob@2.1.2: + resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==} + engines: {node: '>= 4.5.0'} + hasBin: true + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001774: + resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-progress@3.12.0: + resolution: {integrity: sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==} + engines: {node: '>=4'} + + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + css@3.0.0: + resolution: {integrity: sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo-projection@4.0.0: + resolution: {integrity: sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==} + engines: {node: '>=12'} + hasBin: true + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-diff@1.0.2: + resolution: {integrity: sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.1: + resolution: {integrity: sha512-GaUN0sWim5qc8KVErfPBWmc31LEsOkrUJbvJZV+xuL3u2phMUK4HIvXlWAakfC8W4nzlK+chPEAkYOYb5ZScIw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.1.1: + resolution: {integrity: sha512-AVHPqQoZYc+RUM4/3Ly5udlZY/U4LS8pIG05jEjWM2lQMU/oaZ7qshzAl2YP1tfNmXfftH3ohurfwNAug+MnsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-patch@3.1.1: + resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + gonzales-pe@4.3.0: + resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==} + engines: {node: '>=0.6.0'} + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hono@4.12.3: + resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} + engines: {node: '>=16.9.0'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + identifier-regex@1.0.1: + resolution: {integrity: sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw==} + engines: {node: '>=18'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-identifier@1.0.1: + resolution: {integrity: sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA==} + engines: {node: '>=18'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash.sortedlastindex@4.1.0: + resolution: {integrity: sha512-s8xEQdsp2Tu5zUqVdFSe9C0kR8YlnAJYLqMdkh+pIRBRxF6/apWseLdHl3/+jv2I61dhPwtI/Ff+EqvCpc+N8w==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-vue-next@0.526.0: + resolution: {integrity: sha512-YM3i5f56+401tjRP2LMFIG7gtBRd55tom7P9Fl2HfIlYI6+lwl37RO15RDPK4mMHFiFWU0RsVyVQCBnaUdVX8A==} + peerDependencies: + vue: '>=3.0.1' + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-html-parser@7.0.2: + resolution: {integrity: sha512-DxodLVh7a6JMkYzWyc8nBX9MaF4M0lLFYkJHlWOiu7+9/I6mwNK9u5TbAMC7qfqDJEPX9OIoWA2A9t4C2l1mUQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} + + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + postcss-less@6.0.0: + resolution: {integrity: sha512-FPX16mQLyEjLzEuuJtxA8X3ejDLNGGEG503d2YGZR5Ask1SpDN8KmZUMpzCvyalWRywAn1n1VOA5dcqfCLo5rg==} + engines: {node: '>=12'} + peerDependencies: + postcss: ^8.3.5 + + postcss-sass@0.5.0: + resolution: {integrity: sha512-qtu8awh1NMF3o9j/x9j3EZnd+BlF66X6NZYl12BdKoG2Z4hmydOt/dZj2Nq+g0kfk2pQy3jeYFBmvG9DBwynGQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-styl@0.12.3: + resolution: {integrity: sha512-8I7Cd8sxiEITIp32xBK4K/Aj1ukX6vuWnx8oY/oAH35NfQI4OZaY5nd68Yx8HeN5S49uhQ6DL0rNk0ZBu/TaLg==} + engines: {node: ^8.10.0 || ^10.13.0 || ^11.10.1 || >=12.13.0} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + recast-x@1.0.5: + resolution: {integrity: sha512-CkfWKhQiYsMQYaWUkHdERXUxT2jJLBoa5y7zFv3dUAE7Ly5oU/0hsqrENyEfrCL03pDsQYbnoz17Cbagx/c2OA==} + engines: {node: '>= 4'} + + reka-ui@2.8.2: + resolution: {integrity: sha512-8lTKcJhmG+D3UyJxhBnNnW/720sLzm0pbA9AC1MWazmJ5YchJAyTSl+O00xP/kxBmEN0fw5JqWVHguiFmsGjzA==} + peerDependencies: + vue: '>= 3.2.0' + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.2.4: + resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn-vue@2.4.3: + resolution: {integrity: sha512-usi/YjtKyc7wMUt6rk9LspolfNU2O+/X6FCmb+xrYT7kiJ02UPpNTVyAW5gkATm9ZCu7VwOZzklo76XwMArKEA==} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-resolve@0.6.0: + resolution: {integrity: sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==} + deprecated: See https://github.com/lydell/source-map-resolve#deprecated + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sql-formatter@15.7.2: + resolution: {integrity: sha512-b0BGoM81KFRVSpZFwPpIPU5gng4YD8DI/taLD96NXCFRf5af3FzSE4aSwjKmxcyTmf/MfPu91j75883nRrWDBw==} + hasBin: true + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + engines: {node: '>=18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + stringify-object@6.0.0: + resolution: {integrity: sha512-6f94vIED6vmJJfh3lyVsVWxCYSfI5uM+16ntED/Ql37XIyV6kj0mRAAiTeMMc/QLYIaizC3bUprQ8pQnDDrKfA==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stylus@0.57.0: + resolution: {integrity: sha512-yOI6G8WYfr0q8v8rRvE91wbxFU+rJPo760Va4MF6K0I6BZjO4r+xSynkvyPBP9tV1CIEUeRsiidjIs2rzb1CnQ==} + hasBin: true + + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + three@0.173.0: + resolution: {integrity: sha512-AUwVmViIEUgBwxJJ7stnF0NkPpZxx1aZ6WiAbQ/Qq61h6I9UR4grXtZDmO8mnlaNORhHnIBlXJ1uBxILEKuVyw==} + + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.22.0: + resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} + engines: {node: '>=20.18.1'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vaul-vue@0.4.1: + resolution: {integrity: sha512-A6jOWOZX5yvyo1qMn7IveoWN91mJI5L3BUKsIwkg6qrTGgHs1Sb1JF/vyLJgnbN1rH4OOOxFbtqL9A46bOyGUQ==} + peerDependencies: + reka-ui: ^2.0.0 + vue: ^3.3.0 + + vega-canvas@2.0.0: + resolution: {integrity: sha512-9x+4TTw/USYST5nx4yN272sy9WcqSRjAR0tkQYZJ4cQIeon7uVsnohvoPQK1JZu7K1QXGUqzj08z0u/UegBVMA==} + + vega-crossfilter@5.1.0: + resolution: {integrity: sha512-EmVhfP3p6AM7o/lPan/QAoqjblI19BxWUlvl2TSs0xjQd8KbaYYbS4Ixt3cmEvl0QjRdBMF6CdJJ/cy9DTS4Fw==} + + vega-dataflow@6.1.0: + resolution: {integrity: sha512-JxumGlODtFbzoQ4c/jQK8Tb/68ih0lrexlCozcMfTAwQ12XhTqCvlafh7MAKKTMBizjOfaQTHm4Jkyb1H5CfyQ==} + + vega-embed@7.1.0: + resolution: {integrity: sha512-ZmEIn5XJrQt7fSh2lwtSdXG/9uf3yIqZnvXFEwBJRppiBgrEWZcZbj6VK3xn8sNTFQ+sQDXW5sl/6kmbAW3s5A==} + peerDependencies: + vega: '*' + vega-lite: '*' + + vega-encode@5.1.0: + resolution: {integrity: sha512-q26oI7B+MBQYcTQcr5/c1AMsX3FvjZLQOBi7yI0vV+GEn93fElDgvhQiYrgeYSD4Exi/jBPeUXuN6p4bLz16kA==} + + vega-event-selector@4.0.0: + resolution: {integrity: sha512-CcWF4m4KL/al1Oa5qSzZ5R776q8lRxCj3IafCHs5xipoEHrkgu1BWa7F/IH5HrDNXeIDnqOpSV1pFsAWRak4gQ==} + + vega-expression@6.1.0: + resolution: {integrity: sha512-hHgNx/fQ1Vn1u6vHSamH7lRMsOa/yQeHGGcWVmh8fZafLdwdhCM91kZD9p7+AleNpgwiwzfGogtpATFaMmDFYg==} + + vega-force@5.1.0: + resolution: {integrity: sha512-wdnchOSeXpF9Xx8Yp0s6Do9F7YkFeOn/E/nENtsI7NOcyHpICJ5+UkgjUo9QaQ/Yu+dIDU+sP/4NXsUtq6SMaQ==} + + vega-format@2.1.0: + resolution: {integrity: sha512-i9Ht33IgqG36+S1gFDpAiKvXCPz+q+1vDhDGKK8YsgMxGOG4PzinKakI66xd7SdV4q97FgpR7odAXqtDN2wKqw==} + + vega-functions@6.1.1: + resolution: {integrity: sha512-Due6jP0y0FfsGMTrHnzUGnEwXPu7VwE+9relfo+LjL/tRPYnnKqwWvzt7n9JkeBuZqjkgYjMzm/WucNn6Hkw5A==} + + vega-geo@5.1.0: + resolution: {integrity: sha512-H8aBBHfthc3rzDbz/Th18+Nvp00J73q3uXGAPDQqizioDm/CoXCK8cX4pMePydBY9S6ikBiGJrLKFDa80wI20g==} + + vega-hierarchy@5.1.0: + resolution: {integrity: sha512-rZlU8QJNETlB6o73lGCPybZtw2fBBsRIRuFE77aCLFHdGsh6wIifhplVarqE9icBqjUHRRUOmcEYfzwVIPr65g==} + + vega-interpreter@2.2.1: + resolution: {integrity: sha512-o+4ZEme2mdFLewlpF76dwPWW2VkZ3TAF3DMcq75/NzA5KPvnN4wnlCM8At2FVawbaHRyGdVkJSS5ROF5KwpHPQ==} + + vega-label@2.1.0: + resolution: {integrity: sha512-/hgf+zoA3FViDBehrQT42Lta3t8In6YwtMnwjYlh72zNn1p3c7E3YUBwqmAqTM1x+tudgzMRGLYig+bX1ewZxQ==} + + vega-lite@6.4.2: + resolution: {integrity: sha512-Mv2PaRIpijz256LM0NdOJd9Md8cqyrXina54xW6Qp865YfY502zlXGUst+W/XznVwISGfatt0yLZuDqCUbBDuw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + vega: ^6.0.0 + + vega-loader@5.1.0: + resolution: {integrity: sha512-GaY3BdSPbPNdtrBz8SYUBNmNd8mdPc3mtdZfdkFazQ0RD9m+Toz5oR8fKnTamNSk9fRTJX0Lp3uEqxrAlQVreg==} + + vega-parser@7.1.0: + resolution: {integrity: sha512-g0lrYxtmYVW8G6yXpIS4J3Uxt9OUSkc0bLu5afoYDo4rZmoOOdll3x3ebActp5LHPW+usZIE+p5nukRS2vEc7Q==} + + vega-projection@2.1.0: + resolution: {integrity: sha512-EjRjVSoMR5ibrU7q8LaOQKP327NcOAM1+eZ+NO4ANvvAutwmbNVTmfA1VpPH+AD0AlBYc39ND/wnRk7SieDiXA==} + + vega-regression@2.1.0: + resolution: {integrity: sha512-HzC7MuoEwG1rIxRaNTqgcaYF03z/ZxYkQR2D5BN0N45kLnHY1HJXiEcZkcffTsqXdspLjn47yLi44UoCwF5fxQ==} + + vega-runtime@7.1.0: + resolution: {integrity: sha512-mItI+WHimyEcZlZrQ/zYR3LwHVeyHCWwp7MKaBjkU8EwkSxEEGVceyGUY9X2YuJLiOgkLz/6juYDbMv60pfwYA==} + + vega-scale@8.1.0: + resolution: {integrity: sha512-VEgDuEcOec8+C8+FzLcnAmcXrv2gAJKqQifCdQhkgnsLa978vYUgVfCut/mBSMMHbH8wlUV1D0fKZTjRukA1+A==} + + vega-scenegraph@5.1.0: + resolution: {integrity: sha512-4gA89CFIxkZX+4Nvl8SZF2MBOqnlj9J5zgdPh/HPx+JOwtzSlUqIhxFpFj7GWYfwzr/PyZnguBLPihPw1Og/cA==} + + vega-schema-url-parser@3.0.2: + resolution: {integrity: sha512-xAnR7KAvNPYewI3O0l5QGdT8Tv0+GCZQjqfP39cW/hbe/b3aYMAQ39vm8O2wfXUHzm04xTe7nolcsx8WQNVLRQ==} + + vega-selections@6.1.2: + resolution: {integrity: sha512-xJ+V4qdd46nk2RBdwIRrQm2iSTMHdlu/omhLz1pqRL3jZDrkqNBXimrisci2kIKpH2WBpA1YVagwuZEKBmF2Qw==} + + vega-statistics@2.0.0: + resolution: {integrity: sha512-dGPfDXnBlgXbZF3oxtkb8JfeRXd5TYHx25Z/tIoaa9jWua4Vf/AoW2wwh8J1qmMy8J03/29aowkp1yk4DOPazQ==} + + vega-themes@3.0.0: + resolution: {integrity: sha512-1iFiI3BNmW9FrsLnDLx0ZKEddsCitRY3XmUAwp6qmp+p+IXyJYc9pfjlVj9E6KXBPfm4cQyU++s0smKNiWzO4g==} + peerDependencies: + vega: '*' + vega-lite: '*' + + vega-time@3.1.0: + resolution: {integrity: sha512-G93mWzPwNa6UYQRkr8Ujur9uqxbBDjDT/WpXjbDY0yygdSkRT+zXF+Sb4gjhW0nPaqdiwkn0R6kZcSPMj1bMNA==} + + vega-tooltip@1.0.0: + resolution: {integrity: sha512-P1R0JP29v0qnTuwzCQ0SPJlkjAzr6qeyj+H4VgUFSykHmHc1OBxda//XBaFDl/bZgIscEMvjKSjZpXd84x3aZQ==} + + vega-transforms@5.1.0: + resolution: {integrity: sha512-mj/sO2tSuzzpiXX8JSl4DDlhEmVwM/46MTAzTNQUQzJPMI/n4ChCjr/SdEbfEyzlD4DPm1bjohZGjLc010yuMg==} + + vega-typings@2.1.0: + resolution: {integrity: sha512-zdis4Fg4gv37yEvTTSZEVMNhp8hwyEl7GZ4X4HHddRVRKxWFsbyKvZx/YW5Z9Ox4sjxVA2qHzEbod4Fdx+SEJA==} + + vega-util@2.1.0: + resolution: {integrity: sha512-PGfp0m0QCufDmcxKJCWQy4Ov23FoF8DSXmoJwSezi3itQaa2hbxK0+xwsTMP2vy4PR16Pu25HMzgMwXVW1+33w==} + + vega-view-transforms@5.1.0: + resolution: {integrity: sha512-fpigh/xn/32t+An1ShoY3MLeGzNdlbAp2+HvFKzPpmpMTZqJEWkk/J/wHU7Swyc28Ta7W1z3fO+8dZkOYO5TWQ==} + + vega-view@6.1.0: + resolution: {integrity: sha512-hmHDm/zC65lb23mb9Tr9Gx0wkxP0TMS31LpMPYxIZpvInxvUn7TYitkOtz1elr63k2YZrgmF7ztdGyQ4iCQ5fQ==} + + vega-voronoi@5.1.0: + resolution: {integrity: sha512-uKdsoR9x60mz7eYtVG+NhlkdQXeVdMr6jHNAHxs+W+i6kawkUp5S9jp1xf1FmW/uZvtO1eqinHQNwATcDRsiUg==} + + vega-wordcloud@5.1.0: + resolution: {integrity: sha512-sSdNmT8y2D7xXhM2h76dKyaYn3PA4eV49WUUkfYfqHz/vpcu10GSAoFxLhQQTkbZXR+q5ZB63tFUow9W2IFo6g==} + + vega@6.2.0: + resolution: {integrity: sha512-BIwalIcEGysJdQDjeVUmMWB3e50jPDNAMfLJscjEvpunU9bSt7X1OYnQxkg3uBwuRRI4nWfFZO9uIW910nLeGw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue-component-type-helpers@2.2.12: + resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-metamorph@3.3.3: + resolution: {integrity: sha512-NPeDg2/ZL4lDJsC/PjEfFq+Ln3Rr7cX86AQo4bI5V6ziUTZOMY93HyIwKHW37BrIf4YB97llrGtvM+eMhwr1jw==} + hasBin: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-sonner@2.0.9: + resolution: {integrity: sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==} + peerDependencies: + '@nuxt/kit': ^4.0.3 + '@nuxt/schema': ^4.0.3 + nuxt: ^4.0.3 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true + nuxt: + optional: true + + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@8.0.0-alpha.12': {} + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2(jiti@2.6.1))': + dependencies: + eslint: 10.0.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.2': + dependencies: + '@eslint/object-schema': 3.0.2 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.2': + dependencies: + '@eslint/core': 1.1.0 + + '@eslint/core@1.1.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/object-schema@3.0.2': {} + + '@eslint/plugin-kit@0.6.0': + dependencies: + '@eslint/core': 1.1.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/utils@0.2.10': {} + + '@floating-ui/vue@1.1.10(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.7.5 + '@floating-ui/utils': 0.2.10 + vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@hono/node-server@1.19.9(hono@4.12.3)': + dependencies: + hono: 4.12.3 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@internationalized/date@3.11.0': + dependencies: + '@swc/helpers': 0.5.19 + + '@internationalized/number@3.6.5': + dependencies: + '@swc/helpers': 0.5.19 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.3 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@one-ini/wasm@0.1.1': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@radix-ui/colors@3.0.0': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + + '@tanstack/virtual-core@3.13.19': {} + + '@tanstack/vue-virtual@3.13.19(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.13.19 + vue: 3.5.29(typescript@5.9.3) + + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.2.4 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.10.15': + dependencies: + undici-types: 7.16.0 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/web-bluetooth@0.0.20': {} + + '@types/web-bluetooth@0.0.21': {} + + '@unovue/detypes@0.8.5': + dependencies: + '@babel/core': 7.29.0 + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vuedx/template-ast-types': 0.7.1 + fast-glob: 3.3.3 + prettier: 3.8.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0))(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + vue: 3.5.29(typescript@5.9.3) + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/devtools-api@6.6.4': {} + + '@vue/reactivity@3.5.29': + dependencies: + '@vue/shared': 3.5.29 + + '@vue/runtime-core@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/runtime-dom@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29(typescript@5.9.3) + + '@vue/shared@3.5.29': {} + + '@vue/test-utils@2.4.6': + dependencies: + js-beautify: 1.15.4 + vue-component-type-helpers: 2.2.12 + + '@vuedx/template-ast-types@0.7.1': + dependencies: + '@vue/compiler-core': 3.5.29 + + '@vueuse/core@10.11.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.29(typescript@5.9.3)) + vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.2.1 + '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3)) + vue: 3.5.29(typescript@5.9.3) + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/metadata@14.2.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))': + dependencies: + vue: 3.5.29(typescript@5.9.3) + + abbrev@2.0.0: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + ast-types-x@1.18.0: + dependencies: + tslib: 2.8.1 + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + atob@2.1.2: {} + + autoprefixer@10.4.27(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001774 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001774 + electron-to-chromium: 1.5.302 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bytes@3.1.2: {} + + c12@3.3.3: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001774: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@5.6.2: {} + + check-error@2.1.3: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-progress@3.12.0: + dependencies: + string-width: 4.2.3 + + cli-spinners@3.4.0: {} + + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + commander@11.1.0: {} + + commander@13.1.0: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@7.2.0: {} + + concat-map@0.0.1: {} + + confbox@0.2.4: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + consola@3.4.2: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-hrtime@5.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + css@3.0.0: + dependencies: + inherits: 2.0.4 + source-map: 0.6.1 + source-map-resolve: 0.6.0 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo-projection@4.0.0: + dependencies: + commander: 7.2.0 + d3-array: 3.2.4 + d3-geo: 3.1.1 + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-quadtree@3.0.1: {} + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + decode-uri-component@0.2.2: {} + + dedent@1.7.1: {} + + deep-diff@1.0.2: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defu@6.1.4: {} + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + diff@8.0.3: {} + + discontinuous-range@1.0.0: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.7.4 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.302: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.1: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.2 + '@eslint/config-helpers': 0.5.2 + '@eslint/core': 1.1.0 + '@eslint/plugin-kit': 0.6.0 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.1.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + expect-type@1.3.0: {} + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-patch@3.1.1: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@2.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function-timeout@1.0.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.4 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + gonzales-pe@4.3.0: + dependencies: + minimist: 1.2.8 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + hono@4.12.3: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + identifier-regex@1.0.1: + dependencies: + reserved-identifiers: 1.2.0 + + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internmap@2.0.3: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-identifier@1.0.1: + dependencies: + identifier-regex: 1.0.1 + super-regex: 1.1.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jiti@2.6.1: {} + + jose@6.1.3: {} + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.5.0 + js-cookie: 3.0.5 + nopt: 7.2.1 + + js-cookie@3.0.5: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-pretty-compact@4.0.0: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.23: {} + + lodash.sortedlastindex@4.1.0: {} + + lodash.truncate@4.4.2: {} + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + + long@5.3.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@11.2.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-vue-next@0.526.0(vue@3.5.29(typescript@5.9.3)): + dependencies: + vue: 3.5.29(typescript@5.9.3) + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 + + marked@14.0.0: {} + + marked@15.0.12: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + moo@0.5.2: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: {} + + node-fetch-native@1.6.7: {} + + node-html-parser@7.0.2: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + + node-releases@2.0.27: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.23: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.2 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@9.3.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.2.0 + + p-event@6.0.1: + dependencies: + p-timeout: 6.1.4 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-timeout@6.1.4: {} + + package-json-from-dist@1.0.1: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.29(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.29(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pkce-challenge@5.0.1: {} + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + postcss-less@6.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-sass@0.5.0: + dependencies: + gonzales-pe: 4.3.0 + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-styl@0.12.3: + dependencies: + debug: 4.4.3 + fast-diff: 1.3.0 + lodash.sortedlastindex: 4.1.0 + postcss: 8.5.6 + stylus: 0.57.0 + transitivePeerDependencies: + - supports-color + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proto-list@1.2.4: {} + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.10.15 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + readdirp@5.0.0: {} + + recast-x@1.0.5: + dependencies: + ast-types: ast-types-x@1.18.0 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + reka-ui@2.8.2(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.7.5 + '@floating-ui/vue': 1.1.10(vue@3.5.29(typescript@5.9.3)) + '@internationalized/date': 3.11.0 + '@internationalized/number': 3.6.5 + '@tanstack/vue-virtual': 3.13.19(vue@3.5.29(typescript@5.9.3)) + '@vueuse/core': 14.2.1(vue@3.5.29(typescript@5.9.3)) + '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3)) + aria-hidden: 1.2.6 + defu: 6.1.4 + ohash: 2.0.11 + vue: 3.5.29(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + require-from-string@2.0.2: {} + + reserved-identifiers@1.2.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + reusify@1.1.0: {} + + robust-predicates@3.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + sax@1.2.4: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shadcn-vue@2.4.3(eslint@10.0.2(jiti@2.6.1))(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@unovue/detypes': 0.8.5 + '@vue/compiler-sfc': 3.5.29 + c12: 3.3.3 + commander: 14.0.3 + consola: 3.4.2 + dedent: 1.7.1 + deepmerge: 4.3.1 + diff: 8.0.3 + fs-extra: 11.3.3 + fuzzysort: 3.1.0 + get-tsconfig: 4.13.6 + magic-string: 0.30.21 + nypm: 0.6.5 + ofetch: 1.5.1 + ora: 9.3.0 + pathe: 2.0.3 + postcss: 8.5.6 + prompts: 2.4.2 + reka-ui: 2.8.2(vue@3.5.29(typescript@5.9.3)) + semver: 7.7.4 + stringify-object: 6.0.0 + tailwindcss: 4.2.1 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + ts-morph: 27.0.2 + undici: 7.22.0 + vue-metamorph: 3.3.3(eslint@10.0.2(jiti@2.6.1)) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@vue/composition-api' + - babel-plugin-macros + - eslint + - magicast + - supports-color + - vue + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + source-map-js@1.2.1: {} + + source-map-resolve@0.6.0: + dependencies: + atob: 2.1.2 + decode-uri-component: 0.2.2 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + sql-formatter@15.7.2: + dependencies: + argparse: 2.0.1 + nearley: 2.20.1 + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + stdin-discarder@0.3.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + stringify-object@6.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-identifier: 1.0.1 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@2.0.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stylus@0.57.0: + dependencies: + css: 3.0.0 + debug: 4.4.3 + glob: 7.2.3 + safer-buffer: 2.1.2 + sax: 1.2.4 + source-map: 0.7.6 + transitivePeerDependencies: + - supports-color + + super-regex@1.1.0: + dependencies: + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 + + symbol-tree@3.2.4: {} + + table@6.9.0: + dependencies: + ajv: 8.18.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tailwind-merge@3.5.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@4.2.1): + dependencies: + tailwindcss: 4.2.1 + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + three@0.173.0: {} + + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + + tiny-invariant@1.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + topojson-client@3.1.0: + dependencies: + commander: 2.20.3 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.41.0: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@7.16.0: {} + + undici@7.22.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vary@1.1.2: {} + + vaul-vue@0.4.1(reka-ui@2.8.2(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.29(typescript@5.9.3)) + reka-ui: 2.8.2(vue@3.5.29(typescript@5.9.3)) + vue: 3.5.29(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + vega-canvas@2.0.0: {} + + vega-crossfilter@5.1.0: + dependencies: + d3-array: 3.2.4 + vega-dataflow: 6.1.0 + vega-util: 2.1.0 + + vega-dataflow@6.1.0: + dependencies: + vega-format: 2.1.0 + vega-loader: 5.1.0 + vega-util: 2.1.0 + + vega-embed@7.1.0(vega-lite@6.4.2(vega@6.2.0))(vega@6.2.0): + dependencies: + fast-json-patch: 3.1.1 + json-stringify-pretty-compact: 4.0.0 + semver: 7.7.4 + tslib: 2.8.1 + vega: 6.2.0 + vega-interpreter: 2.2.1 + vega-lite: 6.4.2(vega@6.2.0) + vega-schema-url-parser: 3.0.2 + vega-themes: 3.0.0(vega-lite@6.4.2(vega@6.2.0))(vega@6.2.0) + vega-tooltip: 1.0.0 + + vega-encode@5.1.0: + dependencies: + d3-array: 3.2.4 + d3-interpolate: 3.0.1 + vega-dataflow: 6.1.0 + vega-scale: 8.1.0 + vega-util: 2.1.0 + + vega-event-selector@4.0.0: {} + + vega-expression@6.1.0: + dependencies: + '@types/estree': 1.0.8 + vega-util: 2.1.0 + + vega-force@5.1.0: + dependencies: + d3-force: 3.0.0 + vega-dataflow: 6.1.0 + vega-util: 2.1.0 + + vega-format@2.1.0: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-time-format: 4.1.0 + vega-time: 3.1.0 + vega-util: 2.1.0 + + vega-functions@6.1.1: + dependencies: + d3-array: 3.2.4 + d3-color: 3.1.0 + d3-geo: 3.1.1 + vega-dataflow: 6.1.0 + vega-expression: 6.1.0 + vega-scale: 8.1.0 + vega-scenegraph: 5.1.0 + vega-selections: 6.1.2 + vega-statistics: 2.0.0 + vega-time: 3.1.0 + vega-util: 2.1.0 + + vega-geo@5.1.0: + dependencies: + d3-array: 3.2.4 + d3-color: 3.1.0 + d3-geo: 3.1.1 + vega-canvas: 2.0.0 + vega-dataflow: 6.1.0 + vega-projection: 2.1.0 + vega-statistics: 2.0.0 + vega-util: 2.1.0 + + vega-hierarchy@5.1.0: + dependencies: + d3-hierarchy: 3.1.2 + vega-dataflow: 6.1.0 + vega-util: 2.1.0 + + vega-interpreter@2.2.1: + dependencies: + vega-util: 2.1.0 + + vega-label@2.1.0: + dependencies: + vega-canvas: 2.0.0 + vega-dataflow: 6.1.0 + vega-scenegraph: 5.1.0 + vega-util: 2.1.0 + + vega-lite@6.4.2(vega@6.2.0): + dependencies: + json-stringify-pretty-compact: 4.0.0 + tslib: 2.8.1 + vega: 6.2.0 + vega-event-selector: 4.0.0 + vega-expression: 6.1.0 + vega-util: 2.1.0 + yargs: 18.0.0 + + vega-loader@5.1.0: + dependencies: + d3-dsv: 3.0.1 + topojson-client: 3.1.0 + vega-format: 2.1.0 + vega-util: 2.1.0 + + vega-parser@7.1.0: + dependencies: + vega-dataflow: 6.1.0 + vega-event-selector: 4.0.0 + vega-functions: 6.1.1 + vega-scale: 8.1.0 + vega-util: 2.1.0 + + vega-projection@2.1.0: + dependencies: + d3-geo: 3.1.1 + d3-geo-projection: 4.0.0 + vega-scale: 8.1.0 + + vega-regression@2.1.0: + dependencies: + d3-array: 3.2.4 + vega-dataflow: 6.1.0 + vega-statistics: 2.0.0 + vega-util: 2.1.0 + + vega-runtime@7.1.0: + dependencies: + vega-dataflow: 6.1.0 + vega-util: 2.1.0 + + vega-scale@8.1.0: + dependencies: + d3-array: 3.2.4 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + vega-time: 3.1.0 + vega-util: 2.1.0 + + vega-scenegraph@5.1.0: + dependencies: + d3-path: 3.1.0 + d3-shape: 3.2.0 + vega-canvas: 2.0.0 + vega-loader: 5.1.0 + vega-scale: 8.1.0 + vega-util: 2.1.0 + + vega-schema-url-parser@3.0.2: {} + + vega-selections@6.1.2: + dependencies: + d3-array: 3.2.4 + vega-expression: 6.1.0 + vega-util: 2.1.0 + + vega-statistics@2.0.0: + dependencies: + d3-array: 3.2.4 + + vega-themes@3.0.0(vega-lite@6.4.2(vega@6.2.0))(vega@6.2.0): + dependencies: + vega: 6.2.0 + vega-lite: 6.4.2(vega@6.2.0) + + vega-time@3.1.0: + dependencies: + d3-array: 3.2.4 + d3-time: 3.1.0 + vega-util: 2.1.0 + + vega-tooltip@1.0.0: + dependencies: + vega-util: 2.1.0 + + vega-transforms@5.1.0: + dependencies: + d3-array: 3.2.4 + vega-dataflow: 6.1.0 + vega-statistics: 2.0.0 + vega-time: 3.1.0 + vega-util: 2.1.0 + + vega-typings@2.1.0: + dependencies: + '@types/geojson': 7946.0.16 + vega-event-selector: 4.0.0 + vega-expression: 6.1.0 + vega-util: 2.1.0 + + vega-util@2.1.0: {} + + vega-view-transforms@5.1.0: + dependencies: + vega-dataflow: 6.1.0 + vega-scenegraph: 5.1.0 + vega-util: 2.1.0 + + vega-view@6.1.0: + dependencies: + d3-array: 3.2.4 + d3-timer: 3.0.1 + vega-dataflow: 6.1.0 + vega-format: 2.1.0 + vega-functions: 6.1.1 + vega-runtime: 7.1.0 + vega-scenegraph: 5.1.0 + vega-util: 2.1.0 + + vega-voronoi@5.1.0: + dependencies: + d3-delaunay: 6.0.4 + vega-dataflow: 6.1.0 + vega-util: 2.1.0 + + vega-wordcloud@5.1.0: + dependencies: + vega-canvas: 2.0.0 + vega-dataflow: 6.1.0 + vega-scale: 8.1.0 + vega-statistics: 2.0.0 + vega-util: 2.1.0 + + vega@6.2.0: + dependencies: + vega-crossfilter: 5.1.0 + vega-dataflow: 6.1.0 + vega-encode: 5.1.0 + vega-event-selector: 4.0.0 + vega-expression: 6.1.0 + vega-force: 5.1.0 + vega-format: 2.1.0 + vega-functions: 6.1.1 + vega-geo: 5.1.0 + vega-hierarchy: 5.1.0 + vega-label: 2.1.0 + vega-loader: 5.1.0 + vega-parser: 7.1.0 + vega-projection: 2.1.0 + vega-regression: 2.1.0 + vega-runtime: 7.1.0 + vega-scale: 8.1.0 + vega-scenegraph: 5.1.0 + vega-statistics: 2.0.0 + vega-time: 3.1.0 + vega-transforms: 5.1.0 + vega-typings: 2.1.0 + vega-util: 2.1.0 + vega-view: 6.1.0 + vega-view-transforms: 5.1.0 + vega-voronoi: 5.1.0 + vega-wordcloud: 5.1.0 + + vite-node@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + stylus: 0.57.0 + + vitest@3.2.4(@types/node@24.10.15)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.31.1)(stylus@0.57.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + vite-node: 3.2.4(@types/node@24.10.15)(jiti@2.6.1)(lightningcss@1.31.1)(stylus@0.57.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.15 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vue-component-type-helpers@2.2.12: {} + + vue-demi@0.14.10(vue@3.5.29(typescript@5.9.3)): + dependencies: + vue: 3.5.29(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1)): + dependencies: + debug: 4.4.3 + eslint: 10.0.2(jiti@2.6.1) + eslint-scope: 9.1.1 + eslint-visitor-keys: 5.0.1 + espree: 11.1.1 + esquery: 1.7.0 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + + vue-metamorph@3.3.3(eslint@10.0.2(jiti@2.6.1)): + dependencies: + '@babel/parser': 8.0.0-alpha.12 + ast-types-x: 1.18.0 + chalk: 5.6.2 + cli-progress: 3.12.0 + commander: 13.1.0 + deep-diff: 1.0.2 + fs-extra: 11.3.3 + glob: 11.1.0 + lodash-es: 4.17.23 + magic-string: 0.30.21 + micromatch: 4.0.8 + node-html-parser: 7.0.2 + postcss: 8.5.6 + postcss-less: 6.0.0(postcss@8.5.6) + postcss-sass: 0.5.0 + postcss-scss: 4.0.9(postcss@8.5.6) + postcss-styl: 0.12.3 + recast-x: 1.0.5 + table: 6.9.0 + vue-eslint-parser: 10.4.0(eslint@10.0.2(jiti@2.6.1)) + transitivePeerDependencies: + - eslint + - supports-color + + vue-router@4.6.4(vue@3.5.29(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.29(typescript@5.9.3) + + vue-sonner@2.0.9: {} + + vue@3.5.29(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.9.3)) + '@vue/shared': 3.5.29 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + web-worker@1.5.0: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@22.0.0: {} + + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + + yocto-queue@0.1.0: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..b6dc034 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..8c51eb0 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/src/__tests__/ai-chat-composer-css.test.ts b/frontend/src/__tests__/ai-chat-composer-css.test.ts new file mode 100644 index 0000000..5d4a5af --- /dev/null +++ b/frontend/src/__tests__/ai-chat-composer-css.test.ts @@ -0,0 +1,42 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('AI chat composer CSS', () => { + it('uses a boxed composer with growable textarea', () => { + const css = loadStyleCss() + + expect(css).toContain('.ai-composer-box {') + expect(css).toContain('border-radius: 14px;') + expect(css).toContain('.ai-composer-input-area {') + expect(css).toContain('resize: none;') + expect(css).toContain('max-height: 120px;') + }) + + it('keeps model selector and actions aligned on composer footer', () => { + const css = loadStyleCss() + + expect(css).toContain('.ai-composer-toolbar {') + expect(css).toContain('justify-content: space-between;') + expect(css).toContain('.ai-composer-actions {') + expect(css).toContain('.ai-voice-btn {') + expect(css).toContain('.ai-send-circle-btn {') + }) + + it('uses a sparkle icon for the provider trigger', () => { + const css = loadStyleCss() + + expect(css).toContain('.ai-composer-icon {') + expect(css).toContain('border-radius: 50%;') + expect(css).toContain('.ai-composer-icon::before {') + expect(css).toContain('mask-image: url(') + expect(css).toContain('linear-gradient') + }) +}) diff --git a/frontend/src/__tests__/ai-chat-css.test.ts b/frontend/src/__tests__/ai-chat-css.test.ts new file mode 100644 index 0000000..06fb8b0 --- /dev/null +++ b/frontend/src/__tests__/ai-chat-css.test.ts @@ -0,0 +1,65 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const css = readCssWithImports(path.resolve(__dirname, '..', 'style.css')) + +describe('AI chat CSS', () => { + it('defines ai sidebar and context chip overlay', () => { + expect(css).toMatch(/\.ai-sidebar[\s\S]*?\{[\s\S]*?--ai-rail/) + expect(css).toMatch(/--ai-ivory:\s*#f9faef/i) + expect(css).toMatch(/--ai-paper:\s*#f3f4e9/i) + expect(css).toMatch(/--ai-divider:\s*#c5c8ba/i) + expect(css).toMatch(/--ai-ink:\s*#1a1c16/i) + expect(css).toMatch(/--ai-ink-muted:\s*#44483d/i) + expect(css).toMatch(/--ai-sage:\s*#4c662b/i) + expect(css).toMatch(/--ai-amber:\s*#b1d18a/i) + expect(css).toMatch(/\.ai-quick-prompt[\s\S]*?--ai-ivory:\s*#f9faef/i) + expect(css).toMatch(/\.ai-context-chip::after[\s\S]*?content:/) + }) + + it('styles ai history tabs and compact model selector', () => { + const sidebar = css.match(/\.ai-sidebar[\s\S]*?\}/)?.[0] ?? '' + expect(sidebar).toMatch(/padding-bottom:\s*48px/i) + + const historyStrip = css.match(/\.ai-history-strip[\s\S]*?\}/)?.[0] ?? '' + expect(historyStrip).toMatch(/background:\s*transparent/i) + expect(historyStrip).toMatch(/border:\s*none/i) + + const historyTab = css.match(/\.ai-history-tab[\s\S]*?\}/)?.[0] ?? '' + expect(historyTab).toMatch(/border-radius:\s*12px/i) + + const modelTrigger = css.match(/\.ai-model-trigger[\s\S]*?\}/)?.[0] ?? '' + expect(modelTrigger).toMatch(/border:\s*none/i) + expect(modelTrigger).toMatch(/border-radius:\s*8px/i) + + const modelSelect = css.match(/\.ai-model-select[\s\S]*?\}/)?.[0] ?? '' + expect(modelSelect).toMatch(/max-width:\s*calc\(100%\s*-\s*90px\)/i) + }) + + it('adds a glass treatment to the ai toggle button', () => { + const aiToggle = css.match(/\.btn\.ai-toggle[\s\S]*?\}/)?.[0] ?? '' + expect(aiToggle).toMatch(/background:\s*linear-gradient/i) + expect(aiToggle).toMatch(/backdrop-filter:\s*blur/i) + }) + + it('keeps ai sidebar icon buttons at 32px click targets even inside narrow flex rows', () => { + const iconButton = css.match(/\.ai-icon-btn\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(iconButton).toMatch(/width:\s*32px/i) + expect(iconButton).toMatch(/min-width:\s*32px/i) + expect(iconButton).toMatch(/height:\s*32px/i) + expect(iconButton).toMatch(/min-height:\s*32px/i) + expect(iconButton).toMatch(/flex:\s*0\s+0\s+32px/i) + }) + + it('sizes the ai toggle as a 32px icon button without inherited label padding', () => { + const aiToggle = css.match(/\.btn\.ai-toggle[\s\S]*?\}/)?.[0] ?? '' + + expect(aiToggle).toMatch(/width:\s*32px/i) + expect(aiToggle).toMatch(/height:\s*32px/i) + expect(aiToggle).toMatch(/min-height:\s*32px/i) + expect(aiToggle).toMatch(/padding:\s*0/i) + }) +}) diff --git a/frontend/src/__tests__/ai-chat-done-merge.test.ts b/frontend/src/__tests__/ai-chat-done-merge.test.ts new file mode 100644 index 0000000..9c2913e --- /dev/null +++ b/frontend/src/__tests__/ai-chat-done-merge.test.ts @@ -0,0 +1,165 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const runtimeEventHandlers = new Map void>() + +vi.mock('@wailsjs/runtime/runtime', () => ({ + EventsOn: vi.fn((event: string, handler: (payload: any) => void) => { + runtimeEventHandlers.set(event, handler) + return () => runtimeEventHandlers.delete(event) + }), +})) + +vi.mock('@/services/api', () => ({ + api: { + aiChatTurn: vi.fn(), + aiChatTurnStream: vi.fn(), + aiChatApprove: vi.fn(), + aiChatCancelStream: vi.fn(), + }, +})) + +import AiSidebar from '@/components/ai/AiSidebar.vue' +import { api } from '@/services/api' +import { useAiChatStore } from '@/stores/ai-chat' + +const emitRuntimeEvent = (event: string, payload: any) => { + const handler = runtimeEventHandlers.get(event) + if (handler) handler(payload) +} + +beforeEach(() => { + runtimeEventHandlers.clear() + vi.clearAllMocks() + ;(window as any).runtime = undefined +}) + +afterEach(() => { + ;(window as any).runtime = undefined +}) + +describe('ai chat done merge', () => { + it('does not overwrite streamed assistant text on done when finalText differs', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + + ;(api as any).aiChatTurnStream.mockResolvedValue({ streamId: 'stream_1' }) + ;(window as any).runtime = {} + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('hello') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + + expect((api as any).aiChatTurnStream).toHaveBeenCalled() + + const conversationId = store.activeId + expect(conversationId).toBeTruthy() + + emitRuntimeEvent('aichat:delta', { streamId: 'stream_1', conversationId, delta: 'Hello ' }) + emitRuntimeEvent('aichat:delta', { streamId: 'stream_1', conversationId, delta: 'world' }) + + emitRuntimeEvent('aichat:done', { + streamId: 'stream_1', + conversationId, + response: { assistantMessage: 'TOOL_RESULT' }, + }) + await flushPromises() + + const assistants = (store.messagesById[String(conversationId)] || []).filter((m) => m.role === 'assistant') + expect(assistants.map((m) => m.content)).toEqual(['Hello world', 'TOOL_RESULT']) + }) + + it('attaches done metadata to the final assistant message when done creates a new bubble', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + + ;(api as any).aiChatTurnStream.mockResolvedValue({ streamId: 'stream_meta' }) + ;(window as any).runtime = {} + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('hello') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + + const conversationId = store.activeId + expect(conversationId).toBeTruthy() + + emitRuntimeEvent('aichat:delta', { streamId: 'stream_meta', conversationId, delta: 'Hello world' }) + emitRuntimeEvent('aichat:done', { + streamId: 'stream_meta', + conversationId, + response: { assistantMessage: 'TOOL_RESULT', plan: { title: 'Plan Title' }, agent: { mode: 'chatmodel' } }, + }) + await flushPromises() + + const assistants = (store.messagesById[String(conversationId)] || []).filter((m) => m.role === 'assistant') + expect(assistants.map((m) => m.plan?.title || null)).toEqual([null, 'Plan Title']) + }) + + it('replaces streamed assistant text with repaired final text when highly similar', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + + ;(api as any).aiChatTurnStream.mockResolvedValue({ streamId: 'stream_repair' }) + ;(window as any).runtime = {} + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('hello') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + + const conversationId = store.activeId + expect(conversationId).toBeTruthy() + + const streamed = `${'a'.repeat(40)}X${'b'.repeat(40)}` + const repaired = `${'a'.repeat(40)}Y${'b'.repeat(40)}` + + emitRuntimeEvent('aichat:delta', { streamId: 'stream_repair', conversationId, delta: streamed }) + emitRuntimeEvent('aichat:done', { streamId: 'stream_repair', conversationId, response: { assistantMessage: repaired } }) + await flushPromises() + + const assistants = (store.messagesById[String(conversationId)] || []).filter((m) => m.role === 'assistant') + expect(assistants.map((m) => m.content)).toEqual([repaired]) + }) + + it('replaces progress placeholder with final text on done', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + + ;(api as any).aiChatTurnStream.mockResolvedValue({ streamId: 'stream_2' }) + ;(window as any).runtime = {} + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('hello') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + + const conversationId = store.activeId + expect(conversationId).toBeTruthy() + + emitRuntimeEvent('aichat:progress', { streamId: 'stream_2', conversationId, message: 'Thinking…' }) + emitRuntimeEvent('aichat:done', { + streamId: 'stream_2', + conversationId, + response: { assistantMessage: 'FINAL' }, + }) + await flushPromises() + + const assistants = (store.messagesById[String(conversationId)] || []).filter((m) => m.role === 'assistant') + expect(assistants.map((m) => m.content)).toEqual(['FINAL']) + }) +}) diff --git a/frontend/src/__tests__/ai-chat-ime-enter.test.ts b/frontend/src/__tests__/ai-chat-ime-enter.test.ts new file mode 100644 index 0000000..bbab2ec --- /dev/null +++ b/frontend/src/__tests__/ai-chat-ime-enter.test.ts @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/services/api', () => ({ + api: { + aiChatTurn: vi.fn().mockResolvedValue({ assistantMessage: '', approval: null, effects: {} }), + aiChatTurnStream: vi.fn(), + aiChatApprove: vi.fn().mockResolvedValue({ assistantMessage: '', effects: {} }), + }, +})) + +import AiSidebar from '@/components/ai/AiSidebar.vue' +import { api } from '@/services/api' + +describe('ai chat composer IME', () => { + it('does not send when Enter is used to confirm IME composition', async () => { + const pinia = createPinia() + setActivePinia(pinia) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('textarea.ai-composer-input') + + await input.setValue('hello') + await input.trigger('keydown', { key: 'Enter', isComposing: true }) + + expect((api as any).aiChatTurn).not.toHaveBeenCalled() + expect((input.element as HTMLTextAreaElement).value).toBe('hello') + }) +}) diff --git a/frontend/src/__tests__/ai-chat-mock-risk-autoexecute.test.ts b/frontend/src/__tests__/ai-chat-mock-risk-autoexecute.test.ts new file mode 100644 index 0000000..fb9ce91 --- /dev/null +++ b/frontend/src/__tests__/ai-chat-mock-risk-autoexecute.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' + +import { aiChatApi } from '@/services/api/aichat' +import { datasourcesApi } from '@/services/api/datasources' + +const setTrust = (id: string, trust: 'approval' | 'cautious' | 'trusted' | 'danger') => + datasourcesApi.setDatasourceTrustLevel(id, trust) + +describe('ai chat mock risk auto execute', () => { + it('does not auto execute a low-risk statement when the datasource is in approval mode', async () => { + await setTrust('ds_mysql', 'approval') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_low_approval', + pageContext: { currentDatasourceId: 'ds_mysql' }, + messages: [{ role: 'user', content: 'run select * from table_name' }], + } as any) + + expect(response.approval?.kind).toBe('execute_statement') + expect((response.approval as any)?.payload?.trustLevel).toBe('approval') + expect((response.effects as any)?.consoleResult).toBeFalsy() + }) + + it('auto executes a medium-risk statement when the datasource is trusted', async () => { + await setTrust('ds_mysql', 'trusted') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_medium_trusted', + pageContext: { currentDatasourceId: 'ds_mysql' }, + messages: [{ role: 'user', content: 'run select * from table_name no index' }], + } as any) + + expect((response.effects as any)?.consoleResult).toBeTruthy() + expect(response.approval).toBeFalsy() + }) + + it('does NOT auto execute a high-risk statement in trusted mode', async () => { + await setTrust('ds_mysql', 'trusted') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_high_trusted', + pageContext: { currentDatasourceId: 'ds_mysql' }, + messages: [{ role: 'user', content: 'run drop table users' }], + } as any) + + expect(response.approval?.kind).toBe('execute_statement') + expect((response.approval as any)?.payload?.risk?.level).toBe('high') + expect((response.effects as any)?.consoleResult).toBeFalsy() + }) + + it('auto executes a high-risk statement when the datasource is in danger mode', async () => { + await setTrust('ds_mysql', 'danger') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_high_danger', + pageContext: { currentDatasourceId: 'ds_mysql' }, + messages: [{ role: 'user', content: 'run drop table users' }], + } as any) + + expect((response.effects as any)?.consoleResult).toBeTruthy() + expect(response.approval).toBeFalsy() + }) + + it('treats redis single-key reads as low risk and auto executes them by default (cautious)', async () => { + await setTrust('ds_mysql', 'cautious') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_redis_low', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'redis' }, + messages: [{ role: 'user', content: 'run redis get key' }], + } as any) + + expect((response.effects as any)?.consoleResult?.statement).toBe('GET key') + expect(response.approval).toBeFalsy() + }) + + it('auto executes a high-risk redis command when the datasource is in danger mode', async () => { + await setTrust('ds_mysql', 'danger') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_redis_high_danger', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'redis' }, + messages: [{ role: 'user', content: 'run redis flushall' }], + } as any) + + expect((response.effects as any)?.consoleResult?.statement).toBe('FLUSHALL') + expect(response.approval).toBeFalsy() + }) + + it('keeps flushdb distinct from flushall in redis danger-mode mock execution', async () => { + await setTrust('ds_mysql', 'danger') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_redis_flushdb_danger', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'redis' }, + messages: [{ role: 'user', content: 'run redis flushdb' }], + } as any) + + expect((response.effects as any)?.consoleResult?.statement).toBe('FLUSHDB') + expect(response.approval).toBeFalsy() + }) + + it('treats broad elasticsearch cat requests as medium risk and requires approval in cautious mode', async () => { + await setTrust('ds_mysql', 'cautious') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_es_cat', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'elasticsearch' }, + messages: [{ role: 'user', content: 'run elastic cat indices' }], + } as any) + + expect(response.approval?.kind).toBe('execute_statement') + expect((response.approval as any)?.payload?.risk?.level).toBe('medium') + expect((response.effects as any)?.consoleResult).toBeFalsy() + }) + + it('treats nested elasticsearch wildcard queries as medium risk and requires approval in cautious mode', async () => { + await setTrust('ds_mysql', 'cautious') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_es_nested_wildcard', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'elasticsearch' }, + messages: [{ role: 'user', content: 'run elastic search wildcard nested' }], + } as any) + + expect(response.approval?.kind).toBe('execute_statement') + expect((response.approval as any)?.payload?.risk?.level).toBe('medium') + expect((response.effects as any)?.consoleResult).toBeFalsy() + }) + + it('treats chromadb read requests as low risk and keeps the datasource type (cautious)', async () => { + await setTrust('ds_mysql', 'cautious') + const response = await aiChatApi.aiChatTurn({ + conversationId: 'mock_chroma_low', + pageContext: { currentDatasourceId: 'ds_mysql', currentDatasourceType: 'chromadb' }, + messages: [{ role: 'user', content: 'run chromadb get docs' }], + } as any) + + expect((response.effects as any)?.consoleResult?.statement).toContain('POST /collections/futrix_docs/get') + expect((response.effects as any)?.consoleResult?.datasourceType).toBe('chromadb') + expect(response.approval).toBeFalsy() + }) +}) diff --git a/frontend/src/__tests__/ai-chat-pause-cancel.test.ts b/frontend/src/__tests__/ai-chat-pause-cancel.test.ts new file mode 100644 index 0000000..10e238e --- /dev/null +++ b/frontend/src/__tests__/ai-chat-pause-cancel.test.ts @@ -0,0 +1,120 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const runtimeEventHandlers = new Map void>() + +vi.mock('@wailsjs/runtime/runtime', () => ({ + EventsOn: vi.fn((event: string, handler: (payload: any) => void) => { + runtimeEventHandlers.set(event, handler) + return () => runtimeEventHandlers.delete(event) + }), +})) + +vi.mock('@/services/api', () => ({ + api: { + aiChatTurn: vi.fn(), + aiChatTurnStream: vi.fn(), + aiChatApprove: vi.fn(), + aiChatCancelStream: vi.fn(), + }, +})) + +import AiSidebar from '@/components/ai/AiSidebar.vue' +import { api } from '@/services/api' +import { useAiChatStore } from '@/stores/ai-chat' + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const emitRuntimeEvent = (event: string, payload: any) => { + const handler = runtimeEventHandlers.get(event) + if (handler) handler(payload) +} + +beforeEach(() => { + runtimeEventHandlers.clear() + vi.clearAllMocks() + ;(window as any).runtime = undefined +}) + +describe('ai chat pause/cancel', () => { + it('switches Send to Pause while in-flight and cancels without leaving empty assistant bubble', async () => { + const pinia = createPinia() + setActivePinia(pinia) + + const pending = deferred() + ;(api as any).aiChatTurn.mockReturnValue(pending.promise) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('hello') + + const composerButton = wrapper.find('button.ai-send-icon') + await composerButton.trigger('click') + + expect(wrapper.find('button.ai-send-icon').attributes('aria-label')).toBe('Pause') + expect((wrapper.find('button.ai-send-icon').element as HTMLButtonElement).disabled).toBe(false) + expect((wrapper.find('textarea.ai-composer-input').element as HTMLTextAreaElement).disabled).toBe(true) + + await wrapper.find('button.ai-send-icon').trigger('click') + + expect(wrapper.find('button.ai-send-icon').attributes('aria-label')).toBe('Send') + expect((wrapper.find('textarea.ai-composer-input').element as HTMLTextAreaElement).disabled).toBe(false) + + pending.resolve({ assistantMessage: 'should be ignored', approval: null, effects: {} }) + await flushPromises() + + expect(wrapper.findAll('.ai-message.assistant').length).toBe(0) + }) + + it('ignores cancelled event from previous stream when a new stream starts', async () => { + ;(window as any).runtime = {} + + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + + const secondStart = deferred() + ;(api as any).aiChatTurnStream + .mockResolvedValueOnce({ streamId: 'stream_old' }) + .mockImplementationOnce(() => secondStart.promise) + ;(api as any).aiChatCancelStream.mockResolvedValue(true) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('textarea.ai-composer-input') + + await input.setValue('first') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + expect((api as any).aiChatTurnStream).toHaveBeenCalledTimes(1) + + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + expect((api as any).aiChatCancelStream).toHaveBeenCalledWith('stream_old') + + await input.setValue('second') + await wrapper.find('button.ai-send-icon').trigger('click') + await flushPromises() + expect((api as any).aiChatTurnStream).toHaveBeenCalledTimes(2) + + emitRuntimeEvent('aichat:cancelled', { + streamId: 'stream_old', + conversationId: store.activeId, + }) + + secondStart.resolve({ streamId: 'stream_new' }) + await flushPromises() + + expect((api as any).aiChatCancelStream).toHaveBeenCalledTimes(1) + expect((api as any).aiChatCancelStream).not.toHaveBeenCalledWith('stream_new') + }) +}) diff --git a/frontend/src/__tests__/ai-chat-preferences.test.ts b/frontend/src/__tests__/ai-chat-preferences.test.ts new file mode 100644 index 0000000..fcdee64 --- /dev/null +++ b/frontend/src/__tests__/ai-chat-preferences.test.ts @@ -0,0 +1,30 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it } from 'vitest' +import AiChatPreferences from '@/components/ai/AiChatPreferences.vue' +import { useAiChatStore } from '@/stores/ai-chat' + +describe('ai chat preferences', () => { + it('updates default-open and retention prefs', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + const wrapper = mount(AiChatPreferences, { global: { plugins: [pinia] } }) + + await wrapper.find('[data-testid="ai-default-open"]').setValue(false) + await wrapper.find('[data-testid="ai-retention"]').setValue('20') + + expect(store.prefs.defaultOpen).toBe(false) + expect(store.prefs.retention).toBe(20) + }) + + it('does not render the auto-execute risk section (moved to Risk Rules)', () => { + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(AiChatPreferences, { global: { plugins: [pinia] } }) + + expect(wrapper.find('[data-testid="ai-auto-execute-risk-low"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="ai-auto-execute-risk-medium"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="ai-auto-execute-risk-high"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/ai-chat-store.test.ts b/frontend/src/__tests__/ai-chat-store.test.ts new file mode 100644 index 0000000..495b521 --- /dev/null +++ b/frontend/src/__tests__/ai-chat-store.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { useAiChatStore } from '@/stores/ai-chat' + +beforeEach(() => { + const data = new Map() + vi.stubGlobal('localStorage', { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value) }, + removeItem: (key: string) => { data.delete(key) }, + clear: () => { data.clear() }, + key: (index: number) => Array.from(data.keys())[index] ?? null, + get length() { return data.size }, + } as Storage) + setActivePinia(createPinia()) + if (typeof localStorage?.clear === 'function') { + localStorage.clear() + } +}) + +describe('ai chat store', () => { + it('does not expose autoExecuteRiskLevels on prefs after trust-level migration', () => { + const store = useAiChatStore() + expect((store.prefs as any).autoExecuteRiskLevels).toBeUndefined() + }) + + it('strips stale autoExecuteRiskLevels entries from legacy localStorage', () => { + localStorage.setItem('fd_ai_chat_prefs', JSON.stringify({ + defaultOpen: true, + retention: 12, + autoExecuteRiskLevels: ['low'], + })) + + const store = useAiChatStore() + + expect(store.prefs.defaultOpen).toBe(true) + expect(store.prefs.retention).toBe(12) + expect((store.prefs as any).autoExecuteRiskLevels).toBeUndefined() + // Only ["low"] was already the safe default, so no notice is surfaced. + expect(store.legacyAutoExecuteNotice).toBeNull() + // And the cleaned-up prefs blob no longer carries the legacy key. + const persisted = JSON.parse(localStorage.getItem('fd_ai_chat_prefs') || '{}') + expect(persisted.autoExecuteRiskLevels).toBeUndefined() + }) + + it('surfaces a migration notice when legacy autoExecuteRiskLevels went beyond the default', () => { + localStorage.setItem('fd_ai_chat_prefs', JSON.stringify({ + defaultOpen: false, + retention: 50, + autoExecuteRiskLevels: ['low', 'medium', 'high'], + })) + + const store = useAiChatStore() + + expect(store.legacyAutoExecuteNotice).not.toBeNull() + expect(store.legacyAutoExecuteNotice?.levels).toEqual(['low', 'medium', 'high']) + expect(store.legacyAutoExecuteNotice?.strict).toBe(false) + // Notice is persisted so it survives reloads until dismissed. + const stored = JSON.parse(localStorage.getItem('fd_ai_chat_autoexec_migration_v1') || '{}') + expect(stored.levels).toEqual(['low', 'medium', 'high']) + // Legacy key is stripped from the prefs blob. + const persisted = JSON.parse(localStorage.getItem('fd_ai_chat_prefs') || '{}') + expect(persisted.autoExecuteRiskLevels).toBeUndefined() + }) + + it('flags strict legacy (autoExecuteRiskLevels=[]) so it does not silently downgrade', () => { + // Empty list meant "auto-run nothing" under the old global pref. The new + // default trust `cautious` auto-runs low-risk reads, so we must surface + // a dedicated notice rather than pretending the old setting carried over. + localStorage.setItem('fd_ai_chat_prefs', JSON.stringify({ + defaultOpen: false, + retention: 50, + autoExecuteRiskLevels: [], + })) + + const store = useAiChatStore() + + expect(store.legacyAutoExecuteNotice).not.toBeNull() + expect(store.legacyAutoExecuteNotice?.levels).toEqual([]) + expect(store.legacyAutoExecuteNotice?.strict).toBe(true) + const stored = JSON.parse(localStorage.getItem('fd_ai_chat_autoexec_migration_v1') || '{}') + expect(stored.strict).toBe(true) + }) + + it('persists the strict flag across reloads', () => { + localStorage.setItem( + 'fd_ai_chat_autoexec_migration_v1', + JSON.stringify({ levels: [], strict: true }), + ) + + const store = useAiChatStore() + + expect(store.legacyAutoExecuteNotice?.strict).toBe(true) + expect(store.legacyAutoExecuteNotice?.levels).toEqual([]) + }) + + it('dismissLegacyAutoExecuteNotice clears the notice and its persisted marker', () => { + localStorage.setItem('fd_ai_chat_prefs', JSON.stringify({ + autoExecuteRiskLevels: ['medium'], + })) + + const store = useAiChatStore() + expect(store.legacyAutoExecuteNotice).not.toBeNull() + + store.dismissLegacyAutoExecuteNotice() + + expect(store.legacyAutoExecuteNotice).toBeNull() + expect(localStorage.getItem('fd_ai_chat_autoexec_migration_v1')).toBeNull() + }) + + it('trims conversations by retention limit', () => { + const store = useAiChatStore() + store.setRetentionLimit(2) + const a = store.createConversation('First') + const b = store.createConversation('Second') + const c = store.createConversation('Third') + expect(store.conversations.length).toBe(2) + expect(store.conversations.map((c) => c.id)).toEqual([b.id, c.id]) + expect(store.activeId).toBe(c.id) + expect(store.messagesById[a.id]).toBeUndefined() + }) + + it('deletes active conversation and falls back', () => { + const store = useAiChatStore() + const a = store.createConversation('First') + const b = store.createConversation('Second') + store.setActive(a.id) + store.deleteConversation(a.id) + expect(store.activeId).toBe(b.id) + }) +}) diff --git a/frontend/src/__tests__/ai-config-form.test.ts b/frontend/src/__tests__/ai-config-form.test.ts new file mode 100644 index 0000000..b96a18d --- /dev/null +++ b/frontend/src/__tests__/ai-config-form.test.ts @@ -0,0 +1,187 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AIConfigForm from '@/components/AIConfigForm.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' + +const providersFixture = { + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4.1-mini', + models: ['gpt-4.1-mini', 'gpt-4o-mini'], + }, + custom: { + name: 'Custom', + baseUrl: '', + defaultModel: '', + models: [], + }, +} + +describe('AIConfigForm', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listAIProviders').mockResolvedValue(providersFixture as any) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('validates required fields on save', async () => { + const createSpy = vi.spyOn(api, 'createAIConfig').mockResolvedValue({ id: 'ai_1' } as any) + + const wrapper = mount(AIConfigForm, { + props: { visible: true, mode: 'create', inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#aiconfig-form-save').trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.find('#aiconfig-form-errors').text()).toContain('Name is required.') + + await wrapper.find('#ai-name').setValue('Production OpenAI') + await wrapper.find('#aiconfig-form-save').trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.find('#aiconfig-form-errors').text()).toContain('API key is required.') + }) + + it('requires base url for custom provider', async () => { + vi.spyOn(api, 'createAIConfig').mockResolvedValue({ id: 'ai_1' } as any) + + const wrapper = mount(AIConfigForm, { + props: { visible: true, mode: 'create', inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ai-name').setValue('Custom Provider') + await wrapper.find('#ai-provider').setValue('custom') + await flushPromises() + + expect(wrapper.find('#ai-baseurl').exists()).toBe(true) + + await wrapper.find('#ai-apikey').setValue('sk-test') + await wrapper.find('#ai-baseurl').setValue('') + + await wrapper.find('#aiconfig-form-save').trigger('click') + await flushPromises() + + expect(wrapper.find('#aiconfig-form-errors').text()).toContain('Base URL is required for custom provider.') + }) + + it('tests connection and renders status detail', async () => { + const testSpy = vi.spyOn(api, 'testAIConfigPayload').mockResolvedValue({ + connected: true, + latencyMs: 123, + modelInfo: 'gpt-4.1-mini', + } as any) + + const wrapper = mount(AIConfigForm, { + props: { visible: true, mode: 'create', inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ai-name').setValue('Production OpenAI') + await wrapper.find('#ai-apikey').setValue('sk-test') + + await wrapper.find('#aiconfig-form-test').trigger('click') + await flushPromises() + + expect(testSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Production OpenAI', + provider: 'openai', + }), + ) + + const status = wrapper.find('#aiconfig-form-status') + expect(status.text()).toContain('Connected') + expect(status.text()).toContain('gpt-4.1-mini') + expect(status.text()).toContain('123ms') + expect(status.find('.status').classes()).toContain('connected') + }) + + it('shows masked api key preview and fetches full key on show', async () => { + const keySpy = vi.spyOn(api, 'getAIConfigAPIKey').mockResolvedValue('sk-real') + + const store = useAppStore() + store.aiConfigs = [ + { + id: 'ai_1', + name: 'Production OpenAI', + provider: 'openai', + baseUrl: providersFixture.openai.baseUrl, + apiKey: 'sk-o***90e6', + model: 'gpt-4.1-mini', + status: 'connected', + } as any, + ] + + const wrapper = mount(AIConfigForm, { + props: { visible: true, mode: 'edit', configId: 'ai_1', inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + const input = wrapper.find('#ai-apikey') + expect(input.attributes('type')).toBe('text') + expect((input.element as HTMLInputElement).value).toBe('sk-o***90e6') + + await wrapper.find('.ai-visibility-toggle').trigger('click') + await flushPromises() + + expect(keySpy).toHaveBeenCalledWith('ai_1') + expect((wrapper.find('#ai-apikey').element as HTMLInputElement).value).toBe('sk-real') + }) + + it('uses stored api key for preview test when api key unchanged', async () => { + const previewSpy = vi.spyOn(api, 'testAIConfigPreview').mockResolvedValue({ + connected: true, + latencyMs: 123, + modelInfo: 'gpt-4.1-mini', + } as any) + + const store = useAppStore() + store.aiConfigs = [ + { + id: 'ai_1', + name: 'Production OpenAI', + provider: 'openai', + baseUrl: providersFixture.openai.baseUrl, + apiKey: 'sk-o***90e6', + model: 'gpt-4.1-mini', + status: 'connected', + } as any, + ] + + const wrapper = mount(AIConfigForm, { + props: { visible: true, mode: 'edit', configId: 'ai_1', inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#aiconfig-form-test').trigger('click') + await flushPromises() + + expect(previewSpy).toHaveBeenCalledWith( + 'ai_1', + expect.objectContaining({ + apiKey: '', + }), + ) + }) +}) diff --git a/frontend/src/__tests__/ai-config-panel.test.ts b/frontend/src/__tests__/ai-config-panel.test.ts new file mode 100644 index 0000000..eda0d10 --- /dev/null +++ b/frontend/src/__tests__/ai-config-panel.test.ts @@ -0,0 +1,128 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import AIConfigPanel from '@/components/AIConfigPanel.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' + +describe('AIConfigPanel', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders split columns and toggles long status detail', async () => { + const store = useAppStore() + store.aiConfigs = [ + { + id: 'ai_ok', + name: 'OpenAI', + provider: 'openai', + model: 'gpt-4.1-mini', + status: 'connected', + lastLatencyMs: 110, + lastModelInfo: 'gpt-4.1-mini', + } as any, + { + id: 'ai_bad', + name: 'Broken', + provider: 'custom', + model: '', + status: 'failed', + statusDetail: 'x'.repeat(180), + } as any, + ] + + const wrapper = mount(AIConfigPanel, { + props: { visible: true, inline: true, split: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('Connected') + expect(wrapper.text()).toContain('Needs attention') + + const badCard = wrapper.findAll('.ai-card').find((card) => card.text().includes('Broken')) + expect(badCard).toBeTruthy() + + const toggle = badCard!.find('.ai-detail-toggle') + expect(toggle.exists()).toBe(true) + + const detail = badCard!.find('.status-detail') + expect(detail.classes()).not.toContain('expanded') + + await toggle.trigger('click') + await flushPromises() + + expect(badCard!.find('.status-detail').classes()).toContain('expanded') + + wrapper.unmount() + }) + + it('opens action menu, emits edit, and closes on outside click', async () => { + const store = useAppStore() + store.aiConfigs = [ + { id: 'ai_ok', name: 'OpenAI', provider: 'openai', model: 'gpt-4.1-mini', status: 'connected' } as any, + ] + + const wrapper = mount(AIConfigPanel, { + props: { visible: true, inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('.ai-action-toggle').trigger('click') + expect(wrapper.find('.ai-action-dropdown').exists()).toBe(true) + + await wrapper.findAll('.ai-action-item')[0]!.trigger('click') + expect(wrapper.emitted('edit')?.[0]).toEqual(['ai_ok']) + expect(wrapper.find('.ai-action-dropdown').exists()).toBe(false) + + await wrapper.find('.ai-action-toggle').trigger('click') + expect(wrapper.find('.ai-action-dropdown').exists()).toBe(true) + + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushPromises() + + expect(wrapper.find('.ai-action-dropdown').exists()).toBe(false) + + wrapper.unmount() + }) + + it('deletes config after confirmation', async () => { + const store = useAppStore() + store.aiConfigs = [ + { id: 'ai_ok', name: 'OpenAI', provider: 'openai', model: 'gpt-4.1-mini', status: 'connected' } as any, + ] + + const deleteSpy = vi.spyOn(api, 'deleteAIConfig').mockResolvedValue(true as any) + + const wrapper = mount(AIConfigPanel, { + props: { visible: true, inline: true }, + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('.ai-action-toggle').trigger('click') + await wrapper.findAll('.ai-action-item').find((btn) => btn.text() === 'Delete')!.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="aiconfig-delete-confirm-dialog"]').exists()).toBe(true) + + await wrapper.find('[data-testid="aiconfig-delete-confirm"]').trigger('click') + await flushPromises() + + expect(deleteSpy).toHaveBeenCalledWith('ai_ok') + expect(api.listAIConfigs).toHaveBeenCalled() + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/ai-context-order.test.ts b/frontend/src/__tests__/ai-context-order.test.ts new file mode 100644 index 0000000..6ddab97 --- /dev/null +++ b/frontend/src/__tests__/ai-context-order.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { buildContextGroups } from '@/modules/ai/context' + +const datasources = [ + { id: 'ds1', name: 'Main', type: 'mysql' }, + { id: 'ds2', name: 'Analytics', type: 'postgresql' }, +] + +describe('ai context ordering', () => { + it('puts current db/table on top', () => { + const groups = buildContextGroups({ + datasources, + currentDatasourceId: 'ds1', + currentDatabase: 'app_db', + currentEntity: 'users', + }) + const firstGroup = groups[0] + expect(firstGroup.title).toBe('Current') + expect(firstGroup.items[0].label).toContain('app_db') + expect(firstGroup.items[1].label).toContain('users') + }) +}) diff --git a/frontend/src/__tests__/ai-quick-prompt.test.ts b/frontend/src/__tests__/ai-quick-prompt.test.ts new file mode 100644 index 0000000..3acb194 --- /dev/null +++ b/frontend/src/__tests__/ai-quick-prompt.test.ts @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it } from 'vitest' +import AiQuickPrompt from '@/components/ai/AiQuickPrompt.vue' +import { useAppStore } from '@/stores/app' + +const makeDatasource = (id: string, name: string, type: any) => ({ + id, + name, + type, + host: '', + port: 0, +}) + +describe('ai quick prompt', () => { + it('emits send when submitting', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(AiQuickPrompt, { + props: { open: true, x: 10, y: 10 }, + global: { plugins: [pinia] }, + }) + await wrapper.find('input').setValue('hello') + await wrapper.find('form').trigger('submit') + expect(wrapper.emitted('send')?.[0]).toEqual(['hello', []]) + }) + + it('supports keyboard navigation in context dropdown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const wrapper = mount(AiQuickPrompt, { + props: { open: true, x: 10, y: 10 }, + global: { plugins: [pinia] }, + }) + const input = wrapper.find('input') + await input.setValue('@') + await input.trigger('keydown', { key: 'ArrowDown' }) + const items = wrapper.findAll('.ai-context-item') + expect(items.length).toBeGreaterThan(0) + expect(items[1].classes()).toContain('active') + await input.trigger('keydown', { key: 'Enter' }) + expect(wrapper.findAll('.ai-context-chip').length).toBe(1) + }) +}) diff --git a/frontend/src/__tests__/ai-settings-css.test.ts b/frontend/src/__tests__/ai-settings-css.test.ts new file mode 100644 index 0000000..b6c6a01 --- /dev/null +++ b/frontend/src/__tests__/ai-settings-css.test.ts @@ -0,0 +1,20 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('AI settings CSS', () => { + it('uses compact spacing variables in ai-panel', () => { + const css = loadStyleCss() + const block = css.match(/\.ai-panel\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('--ai-font-sm: clamp(9px, 1.6cqw, 12px);') + expect(block).toContain('--ai-pad-md: clamp(7px, 1.4cqw, 10px);') + }) +}) diff --git a/frontend/src/__tests__/ai-sidebar-context.test.ts b/frontend/src/__tests__/ai-sidebar-context.test.ts new file mode 100644 index 0000000..0876c5f --- /dev/null +++ b/frontend/src/__tests__/ai-sidebar-context.test.ts @@ -0,0 +1,487 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import AiSidebar from '@/components/ai/AiSidebar.vue' +import { useAppStore } from '@/stores/app' +import { useAiChatStore } from '@/stores/ai-chat' +import { api } from '@/services/api' +import { tApp } from '@/modules/i18n/appI18n' + +const makeDatasource = (id: string, name: string, type: any) => ({ + id, + name, + type, + host: '', + port: 0, +}) + +describe('ai sidebar context', () => { + it('does not create a placeholder conversation on mount', () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + mount(AiSidebar, { global: { plugins: [pinia] } }) + const chatStore = useAiChatStore() + expect(chatStore.conversations.length).toBe(0) + }) + + it('renders selected context chips', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('.ai-composer-input') + await input.setValue('@') + await wrapper.find('.ai-context-item').trigger('click') + expect(wrapper.findAll('.ai-context-chip').length).toBe(1) + }) + + it('shows a history strip of conversations', () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + const chatStore = useAiChatStore() + chatStore.createConversation('First chat') + chatStore.createConversation('Second chat') + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + expect(wrapper.findAll('.ai-history-tab').length).toBe(2) + }) + + it('ignores new chat when active chat has no messages', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + const chatStore = useAiChatStore() + const convo = chatStore.createConversation('First') + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + await wrapper.find('button[aria-label="New chat"]').trigger('click') + expect(chatStore.conversations.length).toBe(1) + expect(chatStore.activeId).toBe(convo.id) + }) + + it('clears the active chat when messages exist', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + const chatStore = useAiChatStore() + chatStore.createConversation('First') + chatStore.sendMessage('hello', []) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + await wrapper.find('button[aria-label="New chat"]').trigger('click') + expect(chatStore.activeId).toBe(null) + }) + + it('sends when pressing Enter in the composer input', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + const chatStore = useAiChatStore() + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('.ai-composer-input') + await input.setValue('hello') + await input.trigger('keydown', { key: 'Enter' }) + expect(chatStore.conversations.length).toBe(1) + expect(chatStore.messagesById[chatStore.activeId as string][0].content).toBe('hello') + }) + + it('renders an ai icon in the composer', () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + expect(wrapper.find('.ai-composer-icon').exists()).toBe(true) + }) + + it('renders model-first labels in the provider dropdown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + appStore.aiConfigs = [ + { + id: 'cfg-1', + name: 'OpenAI Prod', + provider: 'openai' as any, + baseUrl: '', + apiKey: '', + model: 'gpt-4o-mini', + status: 'connected', + }, + { + id: 'cfg-2', + name: 'Gemini', + provider: 'gemini' as any, + baseUrl: '', + apiKey: '', + model: 'gemini-1.5-flash', + status: 'connected', + }, + ] as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + await wrapper.find('.ai-model-trigger').trigger('click') + const options = wrapper.findAll('.ai-model-option-label') + const optionTexts = options.map((option) => option.text()) + expect(optionTexts).toContain('gpt-4o-mini · OpenAI Prod') + expect(optionTexts).toContain('gemini-1.5-flash · Gemini') + expect(optionTexts).not.toContain('OpenAI Prod · gpt-4o-mini') + }) + + it('shows only connected providers in the dropdown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + appStore.aiConfigs = [ + { + id: 'cfg-1', + name: 'OpenAI Prod', + provider: 'openai' as any, + baseUrl: '', + apiKey: '', + model: 'gpt-4o-mini', + status: 'connected', + }, + { + id: 'cfg-2', + name: 'Gemini Dev', + provider: 'gemini' as any, + baseUrl: '', + apiKey: '', + model: 'gemini-1.5-pro', + status: 'error', + }, + { + id: 'cfg-3', + name: 'DeepSeek', + provider: 'custom' as any, + baseUrl: '', + apiKey: '', + model: 'deepseek-chat', + status: 'CONNECTED', + }, + ] as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + await wrapper.find('.ai-model-trigger').trigger('click') + const optionTexts = wrapper.findAll('.ai-model-option-label').map((node) => node.text()) + + expect(optionTexts).toEqual(['gpt-4o-mini · OpenAI Prod', 'deepseek-chat · DeepSeek']) + expect(optionTexts).not.toContain('gemini-1.5-pro · Gemini Dev') + }) + + it('toggles and selects items in the provider dropdown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + appStore.aiConfigs = [ + { + id: 'cfg-1', + name: 'OpenAI Prod', + provider: 'openai' as any, + baseUrl: '', + apiKey: '', + model: 'gpt-4o-mini', + status: 'connected', + }, + { + id: 'cfg-2', + name: 'Gemini', + provider: 'gemini' as any, + baseUrl: '', + apiKey: '', + model: 'gemini-1.5-flash', + status: 'connected', + }, + ] as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + expect(wrapper.find('.ai-model-menu').exists()).toBe(false) + await wrapper.find('.ai-model-trigger').trigger('click') + expect(wrapper.find('.ai-model-menu').exists()).toBe(true) + const options = wrapper.findAll('.ai-model-option') + await options[1].trigger('click') + expect(wrapper.find('.ai-model-menu').exists()).toBe(false) + expect(wrapper.find('.ai-model-trigger-label').text()).toBe('gemini-1.5-flash · Gemini') + }) + + it('closes the provider dropdown on outside click and supports keyboard select', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + appStore.aiConfigs = [ + { + id: 'cfg-1', + name: 'OpenAI Prod', + provider: 'openai' as any, + baseUrl: '', + apiKey: '', + model: 'gpt-4o-mini', + status: 'connected', + }, + { + id: 'cfg-2', + name: 'Gemini', + provider: 'gemini' as any, + baseUrl: '', + apiKey: '', + model: 'gemini-1.5-flash', + status: 'connected', + }, + ] as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const trigger = wrapper.find('.ai-model-trigger') + await trigger.trigger('keydown', { key: 'Enter' }) + expect(wrapper.find('.ai-model-menu').exists()).toBe(true) + await trigger.trigger('keydown', { key: 'ArrowDown' }) + await trigger.trigger('keydown', { key: 'Enter' }) + expect(wrapper.find('.ai-model-trigger-label').text()).toBe('gemini-1.5-flash · Gemini') + await trigger.trigger('click') + expect(wrapper.find('.ai-model-menu').exists()).toBe(true) + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + expect(wrapper.find('.ai-model-menu').exists()).toBe(false) + }) + + it('orders context items with current selections first', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + const ds = makeDatasource('ds1', 'Main', 'mysql') + ds.database = 'appdb' + appStore.datasources = [ds] + appStore.current = ds as any + appStore.selectedEntity = 'orders' + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('.ai-composer-input') + await input.setValue('@') + const items = wrapper.findAll('.ai-context-item') + expect(items.length).toBeGreaterThan(1) + expect(items[0].text()).toBe('appdb') + expect(items[1].text()).toBe('orders') + }) + + it('supports keyboard navigation in context dropdown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('.ai-composer-input') + await input.setValue('@') + await input.trigger('keydown', { key: 'ArrowDown' }) + const items = wrapper.findAll('.ai-context-item') + expect(items.length).toBeGreaterThan(0) + expect(items[1].classes()).toContain('active') + await input.trigger('keydown', { key: 'Enter' }) + expect(wrapper.findAll('.ai-context-chip').length).toBe(1) + }) + + it('renders provider selector on left and voice/send actions on right in composer footer', () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + appStore.aiConfigs = [ + { + id: 'cfg-1', + name: 'OpenAI Prod', + provider: 'openai' as any, + baseUrl: '', + apiKey: '', + model: 'gpt-4o-mini', + status: 'connected', + }, + ] as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + + expect(wrapper.find('.ai-model-trigger').exists()).toBe(true) + expect(wrapper.find('.ai-composer-actions').exists()).toBe(true) + expect(wrapper.find('button.ai-voice-btn').exists()).toBe(true) + expect((wrapper.find('button.ai-voice-btn').element as HTMLButtonElement).disabled).toBe(true) + expect(wrapper.find('button.ai-send-circle-btn').exists()).toBe(true) + }) + + it('auto-resizes the composer textarea to its scrollHeight', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('textarea.ai-composer-input') + const element = input.element as HTMLTextAreaElement + Object.defineProperty(element, 'scrollHeight', { configurable: true, value: 88 }) + + await input.setValue('line 1\nline 2\nline 3') + await input.trigger('input') + + expect(element.style.height).toBe('88px') + }) + + it('includes pending statement as implicitStatement when auto send is triggered', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const turnSpy = vi.spyOn(api, 'aiChatTurn').mockResolvedValue({ + assistantMessage: 'mock', + approval: null, + effects: {}, + } as any) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const chatStore = useAiChatStore() + chatStore.setPendingContext('SELECT 1;') + chatStore.setDraft(tApp('context.explainLogic')) + chatStore.setAutoSend(true) + + await flushPromises() + + expect(turnSpy).toHaveBeenCalled() + const payload = turnSpy.mock.calls[0]?.[0] as any + expect(payload?.implicitStatement).toBe('SELECT 1;') + const lastMessage = payload?.messages?.[payload.messages.length - 1] + expect(lastMessage?.content).toContain('[implicit_statement]') + expect(lastMessage?.content).toContain('SELECT 1;') + wrapper.unmount() + }) + + it('prefers pending page context metadata for datasource + statement when auto send is triggered', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const turnSpy = vi.spyOn(api, 'aiChatTurn').mockResolvedValue({ + assistantMessage: 'mock', + approval: null, + effects: {}, + } as any) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const chatStore = useAiChatStore() + chatStore.setPendingContext("SELECT * FROM \"orders\" WHERE \"pk\" = 'USER#1';") + chatStore.setPendingPageContext({ + currentDatasourceId: 'ds_console', + currentDatasourceType: 'dynamodb', + currentDatabase: '', + currentEntity: 'orders', + currentStatement: "SELECT * FROM \"orders\" WHERE \"pk\" = 'USER#1';", + }) + chatStore.setDraft(tApp('context.explainLogic')) + chatStore.setAutoSend(true) + + await flushPromises() + + expect(turnSpy).toHaveBeenCalled() + const payload = turnSpy.mock.calls[0]?.[0] as any + expect(payload?.pageContext?.currentDatasourceId).toBe('ds_console') + expect(payload?.pageContext?.currentDatasourceType).toBe('dynamodb') + expect(payload?.pageContext?.currentEntity).toBe('orders') + expect(payload?.pageContext?.currentStatement).toBe("SELECT * FROM \"orders\" WHERE \"pk\" = 'USER#1';") + wrapper.unmount() + }) + + it('renders user implicit statement context in chat stream', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const chatStore = useAiChatStore() + chatStore.createConversation('Implicit') + chatStore.sendMessage(tApp('context.explainLogic'), [], 'SELECT * FROM users LIMIT 5;') + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + await nextTick() + + expect(wrapper.text()).toContain(tApp('ai.sidebar.statementContext')) + expect(wrapper.text()).toContain('SELECT * FROM users LIMIT 5;') + wrapper.unmount() + }) + + it('renders plan markdown and workflow tabs when response contains a plan', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const appStore = useAppStore() + appStore.datasources = [makeDatasource('ds1', 'Main', 'mysql')] + appStore.current = makeDatasource('ds1', 'Main', 'mysql') as any + + const turnSpy = vi.spyOn(api, 'aiChatTurn').mockResolvedValue({ + assistantMessage: '', + approval: null, + effects: {}, + agent: { + mode: 'plan_executor', + complexity: 'complex', + reason: 'Multi-step task', + }, + plan: { + title: 'Execution Plan', + summary: 'Safely complete the task', + markdown: '1. Inspect\\n2. Plan\\n3. Execute', + steps: [ + { id: 's1', title: 'Inspect', description: 'Read current schema', status: 'completed' }, + { id: 's2', title: 'Execute', description: 'Run statement with checks', status: 'in_progress' }, + ], + }, + } as any) + + const wrapper = mount(AiSidebar, { global: { plugins: [pinia] } }) + const input = wrapper.find('textarea.ai-composer-input') + await input.setValue('plan this task') + await wrapper.find('button.ai-send-circle-btn').trigger('click') + await flushPromises() + + expect(turnSpy).toHaveBeenCalled() + expect(wrapper.find('.ai-plan-card').exists()).toBe(true) + expect(wrapper.find('.ai-plan-tab.active').text()).toBe(tApp('ai.sidebar.plan.tab.markdown')) + expect(wrapper.find('.ai-plan-agent').text()).toContain(tApp('ai.sidebar.agent.planExecutor')) + + const tabs = wrapper.findAll('.ai-plan-tab') + expect(tabs.length).toBe(2) + await tabs[1].trigger('click') + expect(wrapper.findAll('.ai-plan-step').length).toBe(2) + expect(wrapper.text()).toContain(tApp('ai.sidebar.plan.status.completed')) + }) +}) diff --git a/frontend/src/__tests__/ai-visualization-mock-vega-lite.test.ts b/frontend/src/__tests__/ai-visualization-mock-vega-lite.test.ts new file mode 100644 index 0000000..4f2dceb --- /dev/null +++ b/frontend/src/__tests__/ai-visualization-mock-vega-lite.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' + +import { aiChatApi } from '@/services/api/aichat' + +describe('aiChatApi mock visualization (vega_lite)', () => { + it('returns vega_lite renderer after approving create_visualization', async () => { + const conversationId = 'convo_viz_mock_1' + + await aiChatApi.aiChatTurn({ + conversationId, + messages: [{ role: 'user', content: 'run select * from table_name' }], + } as any) + + const ask = await aiChatApi.aiChatTurn({ + conversationId, + messages: [{ role: 'user', content: 'visualize the result' }], + } as any) + + expect(ask.approval?.kind).toBe('create_visualization') + expect(ask.approval?.id).toBeTruthy() + + const approved = await aiChatApi.aiChatApprove({ + conversationId, + approvalId: ask.approval!.id, + decision: 'approve', + } as any) + + expect((approved.effects as any)?.visualization?.renderer).toBe('vega_lite') + expect((approved.effects as any)?.visualization?.spec).toBeTruthy() + }) +}) diff --git a/frontend/src/__tests__/app-i18n-console-wording.test.ts b/frontend/src/__tests__/app-i18n-console-wording.test.ts new file mode 100644 index 0000000..1a35ed2 --- /dev/null +++ b/frontend/src/__tests__/app-i18n-console-wording.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('app i18n console wording', () => { + beforeEach(() => { + resetAppI18nForTest() + setAppLocale('en') + }) + + it('stores explain and danger wording directly in app i18n', () => { + setAppLocale('en') + expect(tApp('explain.title')).toBe('Explain Plan') + expect(tApp('danger.runAnyway')).toBe('Run anyway') + + setAppLocale('zh') + expect(tApp('explain.title')).toBe('执行计划') + expect(tApp('danger.runAnyway')).toBe('仍然执行') + }) + + it('keeps chroma result detail labels in app i18n', () => { + setAppLocale('en') + expect(tApp('console.chroma.results.metaId')).toBe('ID') + expect(tApp('console.chroma.results.metaDistance')).toBe('Distance') + + setAppLocale('zh') + expect(tApp('console.chroma.results.metaId')).toBe('ID') + expect(tApp('console.chroma.results.metaDistance')).toBe('距离') + }) +}) diff --git a/frontend/src/__tests__/app-i18n.test.ts b/frontend/src/__tests__/app-i18n.test.ts new file mode 100644 index 0000000..0921065 --- /dev/null +++ b/frontend/src/__tests__/app-i18n.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { + getAppLocale, + initAppI18n, + resetAppI18nForTest, + setAppLocale, + tApp, +} from '@/modules/i18n/appI18n' + +const storage = new Map() +const mockLocalStorage = { + getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null), + setItem: (key: string, value: string) => { + storage.set(key, String(value)) + }, + removeItem: (key: string) => { + storage.delete(key) + }, +} + +describe('app i18n', () => { + beforeEach(() => { + storage.clear() + Object.defineProperty(globalThis, 'localStorage', { + value: mockLocalStorage, + configurable: true, + }) + document.documentElement.lang = '' + resetAppI18nForTest() + }) + + it('defaults to en when no locale is stored', () => { + initAppI18n() + expect(getAppLocale()).toBe('en') + expect(document.documentElement.lang).toBe('en') + }) + + it.each(['zh', 'ja', 'es', 'de'] as const)('supports %s locale and persists it', (nextLocale) => { + setAppLocale(nextLocale) + expect(getAppLocale()).toBe(nextLocale) + expect(document.documentElement.lang).toBe(nextLocale) + + resetAppI18nForTest() + initAppI18n() + expect(getAppLocale()).toBe(nextLocale) + }) + + it('falls back to en when stored locale is invalid', () => { + localStorage.setItem('futrix.app.locale', 'fr') + initAppI18n() + expect(getAppLocale()).toBe('en') + }) + + it('normalizes stored locale variants for newly supported languages', () => { + localStorage.setItem('futrix.app.locale', 'ja-JP') + initAppI18n() + expect(getAppLocale()).toBe('ja') + + resetAppI18nForTest() + localStorage.setItem('futrix.app.locale', 'es-MX') + initAppI18n() + expect(getAppLocale()).toBe('es') + + resetAppI18nForTest() + localStorage.setItem('futrix.app.locale', 'de-DE') + initAppI18n() + expect(getAppLocale()).toBe('de') + }) + + it('returns key text fallback for missing translation', () => { + setAppLocale('en') + expect(tApp('missing.key')).toBe('missing.key') + }) + + it('falls back to en wording when selected locale does not define a key', () => { + setAppLocale('ja') + expect(tApp('console.statement.explain')).toBe('Explain') + + setAppLocale('es') + expect(tApp('console.statement.explain')).toBe('Explain') + + setAppLocale('de') + expect(tApp('console.statement.explain')).toBe('Explain') + }) + + it('interpolates translation params by key', () => { + setAppLocale('en') + expect(tApp('common.count', { count: 3 })).toBe('3 item(s)') + setAppLocale('zh') + expect(tApp('common.count', { count: 3 })).toBe('3 项') + }) + + it('translates shared dialog close labels', () => { + setAppLocale('en') + expect(tApp('common.close')).toBe('Close') + + setAppLocale('zh') + expect(tApp('common.close')).toBe('关闭') + }) + + it('uses locale-correct explain label for console statement action', () => { + setAppLocale('en') + expect(tApp('console.statement.explain')).toBe('Explain') + + setAppLocale('zh') + expect(tApp('console.statement.explain')).toBe('解释') + }) + + it('translates sensitivity agent source labels', () => { + setAppLocale('en') + expect(tApp('sensitivity.source.agent')).toBe('Agent') + + setAppLocale('zh') + expect(tApp('sensitivity.source.agent')).toBe('Agent') + }) + + it('does not throw when reading locale from storage fails', () => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => { + throw new Error('storage read blocked') + }, + setItem: () => {}, + }, + configurable: true, + }) + + expect(() => initAppI18n()).not.toThrow() + expect(getAppLocale()).toBe('en') + }) + + it('does not throw when persisting locale fails and still updates in-memory locale', () => { + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: () => null, + setItem: () => { + throw new Error('storage write blocked') + }, + }, + configurable: true, + }) + + expect(() => initAppI18n()).not.toThrow() + expect(() => setAppLocale('zh')).not.toThrow() + expect(getAppLocale()).toBe('zh') + expect(document.documentElement.lang).toBe('zh') + }) +}) diff --git a/frontend/src/__tests__/client-error-logging.test.ts b/frontend/src/__tests__/client-error-logging.test.ts new file mode 100644 index 0000000..a7be4e1 --- /dev/null +++ b/frontend/src/__tests__/client-error-logging.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { installClientErrorLogging } from '@/modules/logging/clientErrors' + +describe('client error logging', () => { + let cleanup: (() => void) | null = null + + afterEach(() => { + cleanup?.() + cleanup = null + vi.restoreAllMocks() + }) + + it('reports window error events', async () => { + const report = vi.fn().mockResolvedValue(undefined) + cleanup = installClientErrorLogging(report) + + const error = new Error('boom') + window.dispatchEvent(new ErrorEvent('error', { message: 'boom', error })) + await Promise.resolve() + + expect(report).toHaveBeenCalledWith('error', 'boom', expect.stringContaining('boom')) + }) + + it('reports unhandled promise rejections', async () => { + const report = vi.fn().mockResolvedValue(undefined) + cleanup = installClientErrorLogging(report) + + const event = new Event('unhandledrejection') as PromiseRejectionEvent + Object.defineProperty(event, 'reason', { value: new Error('reject boom') }) + window.dispatchEvent(event) + await Promise.resolve() + + expect(report).toHaveBeenCalledWith('unhandledrejection', 'Unhandled promise rejection', expect.stringContaining('reject boom')) + }) +}) diff --git a/frontend/src/__tests__/console-ai-quick-prompt.test.ts b/frontend/src/__tests__/console-ai-quick-prompt.test.ts new file mode 100644 index 0000000..e8ae946 --- /dev/null +++ b/frontend/src/__tests__/console-ai-quick-prompt.test.ts @@ -0,0 +1,110 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { useAiChatStore } from '@/stores/ai-chat' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView AI context action', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + const add = window.addEventListener.bind(window) + const remove = window.removeEventListener.bind(window) + vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { + if (type === 'click') { + document.addEventListener(type, listener as EventListener, options) + return + } + add(type, listener, options) + }) + vi.spyOn(window, 'removeEventListener').mockImplementation((type, listener, options) => { + if (type === 'click') { + document.removeEventListener(type, listener as EventListener, options) + return + } + remove(type, listener, options) + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('opens AI sidebar and stores statement context when choosing Ask AI from context menu', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: 'localhost', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = getConsoleStatementInput(wrapper) + await statementInput.setValue('SELECT 1 AS id;') + await statementInput.trigger('contextmenu', { clientX: 8, clientY: 8 }) + await nextTick() + await wrapper.get('[data-testid="statement-context-ask-ai"]').trigger('click') + await flushPromises() + + const aiStore = useAiChatStore() + expect(aiStore.isOpen).toBe(true) + expect(aiStore.autoSend).toBe(true) + expect(String(aiStore.pendingContext || '')).toContain('SELECT 1 AS id') + expect(aiStore.pendingPageContext?.currentDatasourceId).toBe('ds_mysql') + + wrapper.unmount() + }) + + it('applies AI consoleResult effects to the statement and results', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: 'localhost', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const aiStore = useAiChatStore() + aiStore.setConsoleResult({ + datasourceId: 'ds_mysql', + datasourceType: 'mysql', + database: 'appdb', + statement: 'SELECT 1 AS id;', + result: { columns: ['id'], rows: [{ id: 1 }], rowCount: 1, elapsedMs: 12 }, + } as any) + + await flushPromises() + await nextTick() + + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 1 AS id;') + expect(wrapper.find('#result-meta').text()).toContain('Rows: 1') + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/console-api-wails-args.test.ts b/frontend/src/__tests__/console-api-wails-args.test.ts new file mode 100644 index 0000000..4552a08 --- /dev/null +++ b/frontend/src/__tests__/console-api-wails-args.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { consoleApi } from '@/services/api/console' + +describe('console api Wails argument contract', () => { + afterEach(() => { + vi.restoreAllMocks() + delete (window as any).go + }) + + it('passes fixed DynamoDB execution limit arguments to App.ExecuteStatement', async () => { + const executeStatement = vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }) + ;(window as any).go = { + main: { + App: { + ExecuteStatement: executeStatement, + }, + }, + } + + await consoleApi.executeStatement('ds_dynamo', 'SELECT * FROM "orders"', '', '', 25, '', false, { + maxReturnedRows: 40, + maxPages: 3, + maxEvaluatedItems: 300, + }) + + expect(executeStatement).toHaveBeenCalledWith( + 'ds_dynamo', + 'SELECT * FROM "orders"', + '', + '', + 25, + '', + false, + 40, + 3, + 300, + ) + }) +}) diff --git a/frontend/src/__tests__/console-autocomplete-insert.test.ts b/frontend/src/__tests__/console-autocomplete-insert.test.ts new file mode 100644 index 0000000..4e9d595 --- /dev/null +++ b/frontend/src/__tests__/console-autocomplete-insert.test.ts @@ -0,0 +1,64 @@ +import { computed, defineComponent, ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' + +import { useAppStore } from '@/stores/app' +import { useAutocomplete } from '@/views/console/composables/useAutocomplete' + +describe('console autocomplete insert behavior', () => { + it('inserts valid bracket-style mongo collection accessor', async () => { + setActivePinia(createPinia()) + const store = useAppStore() + store.current = { id: 'ds_mongo', type: 'mongodb' } as any + store.entities = ['users'] + + const statement = ref('db["us') + const textarea = document.createElement('textarea') + textarea.value = statement.value + textarea.selectionStart = statement.value.length + textarea.selectionEnd = statement.value.length + + let autocompleteApi: ReturnType | null = null + + const Host = defineComponent({ + setup() { + autocompleteApi = useAutocomplete({ + statement, + statementInput: ref(textarea), + statementShell: ref(document.createElement('div')), + entityDetail: ref(null), + isMongo: computed(() => true), + isElastic: computed(() => false), + isSQL: computed(() => false), + }) + return {} + }, + template: '
', + }) + + mount(Host) + + const getAutocompleteSuggestions = autocompleteApi!.getAutocompleteSuggestions + const showAutocomplete = autocompleteApi!.showAutocomplete + const selectAutocompleteItem = autocompleteApi!.selectAutocompleteItem + + const suggestion = getAutocompleteSuggestions(statement.value, statement.value.length) + expect(suggestion).not.toBeNull() + + const usersItem = suggestion?.items.find((item) => item.label === 'users') + expect(usersItem).toBeTruthy() + + showAutocomplete( + suggestion?.items || [], + suggestion?.title || '', + suggestion?.insertStart || 0, + suggestion?.insertEnd || 0, + suggestion?.prefix || '', + ) + selectAutocompleteItem(usersItem!) + await Promise.resolve() + + expect(statement.value).toBe('db["users"].') + }) +}) diff --git a/frontend/src/__tests__/console-autocomplete-suggestions.test.ts b/frontend/src/__tests__/console-autocomplete-suggestions.test.ts new file mode 100644 index 0000000..db1d279 --- /dev/null +++ b/frontend/src/__tests__/console-autocomplete-suggestions.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { getAutocompleteSuggestions } from '@/views/console/composables/autocomplete/suggestions' + +describe('console autocomplete suggestions', () => { + it('offers SQL keyword suggestions when typing a keyword prefix', () => { + const suggestion = getAutocompleteSuggestions({ + text: 'sel', + cursorPos: 3, + entities: [], + entityDetail: null, + isMongo: false, + isElastic: false, + isSQL: true, + }) + + expect(suggestion).not.toBeNull() + expect(suggestion?.items.some((item) => item.label === 'SELECT')).toBe(true) + }) + + it('offers SQL follow-up keywords after trailing whitespace', () => { + const suggestion = getAutocompleteSuggestions({ + text: 'SELECT * FROM users ', + cursorPos: 'SELECT * FROM users '.length, + entities: ['users', 'orders'], + entityDetail: null, + isMongo: false, + isElastic: false, + isSQL: true, + }) + + expect(suggestion).not.toBeNull() + expect(suggestion?.items.some((item) => item.label === 'WHERE')).toBe(true) + }) + + it('offers Mongo method suggestions for bracket-style collection access', () => { + const text = 'db["users"].f' + const suggestion = getAutocompleteSuggestions({ + text, + cursorPos: text.length, + entities: ['users', 'orders'], + entityDetail: null, + isMongo: true, + isElastic: false, + isSQL: false, + }) + + expect(suggestion).not.toBeNull() + expect(suggestion?.items.some((item) => item.label === 'find()')).toBe(true) + }) + + it('offers Elasticsearch index suggestions when typing request path', () => { + const text = 'GET /fut' + const suggestion = getAutocompleteSuggestions({ + text, + cursorPos: text.length, + entities: ['futrixdata-demo-1', 'futrixdata-demo-2'], + entityDetail: null, + isMongo: false, + isElastic: true, + isSQL: false, + }) + + expect(suggestion).not.toBeNull() + expect(suggestion?.items.some((item) => item.label === 'futrixdata-demo-1')).toBe(true) + }) + + it('offers SQL starter keywords when editor is empty', () => { + const suggestion = getAutocompleteSuggestions({ + text: '', + cursorPos: 0, + entities: ['users'], + entityDetail: null, + isMongo: false, + isElastic: false, + isSQL: true, + }) + + expect(suggestion).not.toBeNull() + expect(suggestion?.items.some((item) => item.label === 'SELECT')).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/console-chroma-dsl-workspace.test.ts b/frontend/src/__tests__/console-chroma-dsl-workspace.test.ts new file mode 100644 index 0000000..eea0378 --- /dev/null +++ b/frontend/src/__tests__/console-chroma-dsl-workspace.test.ts @@ -0,0 +1,173 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import ConsoleChromaDslWorkspace from '@/views/console/components/chroma-dsl/ConsoleChromaDslWorkspace.vue' + +describe('ConsoleChromaDslWorkspace', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('preserves valid raw request edits when running a search via live DSL editor', async () => { + const wrapper = mount(ConsoleChromaDslWorkspace, { + global: { plugins: [createPinia()] }, + props: { + datasourceId: 'ds_chroma', + statement: 'POST /collections/docs/query\n{"n_results":50,"query_texts":["alpha"],"include":["documents","metadatas","distances"]}', + selectedTargetPath: 'docs', + collectionDimension: 0, + canExecute: true, + }, + }) + + // Open the live DSL editor + await wrapper.get('#chroma-live-dsl-toggle').setValue(true) + + // Edit the DSL body directly in the textarea + const dslEditor = wrapper.get('.chroma-dsl-editor') + await dslEditor.setValue('{\n "n_results": 5,\n "query_texts": ["alpha"],\n "include": ["documents", "distances"],\n "custom_flag": true\n}') + + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + + const executePayload = String(wrapper.emitted('execute')?.[0]?.[0] || '') + + expect(executePayload).toContain('"custom_flag": true') + expect(executePayload).toContain('"query_texts": [') + }) + + it('preserves query_texts when rebuilding a structured request', async () => { + const wrapper = mount(ConsoleChromaDslWorkspace, { + global: { plugins: [createPinia()] }, + props: { + datasourceId: 'ds_chroma', + statement: 'POST /collections/docs/query\n{"n_results":50,"query_texts":["alpha"],"include":["documents","metadatas","distances"]}', + selectedTargetPath: 'docs', + collectionDimension: 0, + canExecute: true, + }, + }) + + await wrapper.get('[data-testid="chroma-dsl-chip-metadatas"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + + const executePayload = String(wrapper.emitted('execute')?.[0]?.[0] || '') + + expect(executePayload).toContain('"query_texts": [') + expect(executePayload).toContain('"alpha"') + expect(executePayload).not.toContain('"query_embeddings"') + }) + + it('runs the live DSL request directly in text mode instead of recomputing embeddings', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAppStore() + store.embeddingConfigs = [ + { + id: 'emb-openai', + name: 'OpenAI embeddings', + provider: 'openai', + model: 'text-embedding-3-small', + purpose: 'embedding', + } as any, + ] + + const computeSpy = vi.spyOn(api, 'computeEmbeddingForSearch').mockResolvedValue([0.1, 0.2, 0.3]) + + const wrapper = mount(ConsoleChromaDslWorkspace, { + global: { plugins: [pinia] }, + props: { + datasourceId: 'ds_chroma', + statement: 'POST /collections/docs/query\n{"n_results":3,"query_texts":["alpha"],"include":["documents","metadatas","distances"]}', + selectedTargetPath: 'docs', + collectionDimension: 1536, + canExecute: true, + }, + }) + + await wrapper.get('[data-testid="chroma-dsl-mode-query"]').trigger('click') + const searchModeButtons = wrapper.findAll('.chroma-dsl-search-mode-chip') + await searchModeButtons[1]!.trigger('click') + await wrapper.get('#chroma-live-dsl-toggle').setValue(true) + const dslEditor = wrapper.get('.chroma-dsl-editor') + await dslEditor.setValue('{\n "n_results": 3,\n "query_texts": ["manual body"],\n "include": ["documents"]\n}') + await flushPromises() + + const runButton = wrapper.get('[data-testid="chroma-dsl-run-search"]') + expect((runButton.element as HTMLButtonElement).disabled).toBe(false) + + await runButton.trigger('click') + + expect(computeSpy).not.toHaveBeenCalled() + expect(String(wrapper.emitted('execute')?.[0]?.[0] || '')).toContain('"manual body"') + }) + + it('keeps max_distance = 0 in the generated request body', async () => { + const wrapper = mount(ConsoleChromaDslWorkspace, { + global: { plugins: [createPinia()] }, + props: { + datasourceId: 'ds_chroma', + statement: '', + selectedTargetPath: 'docs', + collectionDimension: 0, + canExecute: true, + }, + }) + + await wrapper.get('[data-testid="chroma-dsl-mode-query"]').trigger('click') + await wrapper.get('[data-testid="chroma-dsl-query-embeddings"]').setValue('[0.1, 0.2, 0.3]') + await wrapper.get('[data-testid="chroma-dsl-max-distance"]').setValue('0') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + + const executePayload = String(wrapper.emitted('execute')?.[0]?.[0] || '') + + expect(executePayload).toContain('"max_distance": 0') + }) + + it('does not throw on malformed encoded collection ids', () => { + const mountWorkspace = () => mount(ConsoleChromaDslWorkspace, { + global: { plugins: [createPinia()] }, + props: { + datasourceId: 'ds_chroma', + statement: 'POST /collections/%E0%A4%A/query\n{"n_results":50,"query_texts":["alpha"],"include":["documents"]}', + selectedTargetPath: 'docs', + collectionDimension: 0, + canExecute: true, + }, + }) + + expect(mountWorkspace).not.toThrow() + const wrapper = mountWorkspace() + expect(wrapper.find('[data-testid="chroma-dsl-workspace"]').exists()).toBe(true) + }) + + it('parses API-prefixed Chroma request lines without losing target or mode', async () => { + const wrapper = mount(ConsoleChromaDslWorkspace, { + global: { plugins: [createPinia()] }, + props: { + datasourceId: 'ds_chroma', + statement: 'POST /api/v2/tenants/default_tenant/databases/default_database/collections/docs/query\n{"n_results":2,"query_texts":["alpha"],"include":["documents","distances"]}', + selectedTargetPath: '', + collectionDimension: 0, + canExecute: true, + }, + }) + + expect(wrapper.find('[data-testid="chroma-dsl-mode-query"]').classes()).toContain('active') + + await wrapper.get('[data-testid="chroma-dsl-chip-metadatas"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + + const executePayload = String(wrapper.emitted('execute')?.[0]?.[0] || '') + + expect(executePayload).toContain('POST /collections/docs/query') + expect(executePayload).toContain('"query_texts": [') + expect(executePayload).toContain('"alpha"') + }) +}) diff --git a/frontend/src/__tests__/console-chromadb-entity-expand.test.ts b/frontend/src/__tests__/console-chromadb-entity-expand.test.ts new file mode 100644 index 0000000..ba6719c --- /dev/null +++ b/frontend/src/__tests__/console-chromadb-entity-expand.test.ts @@ -0,0 +1,73 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_chroma' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView ChromaDB entity details', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'ID', value: '123e4567-e89b-12d3-a456-426614174000' }, + { label: 'Dimension', value: 1536 }, + { label: 'Records', value: 2048 }, + { label: 'Metadata', value: '{"tenant":"demo","topic":"guides"}' }, + ], + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders collection summary details in the expanded entity panel', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_chroma', + name: 'ChromaDB', + type: 'chromadb', + host: 'localhost', + port: 8000, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any, + ] + vi.spyOn(api, 'listEntities').mockResolvedValue(['docs'] as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('.entity-item').trigger('click') + await flushPromises() + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + expect(wrapper.find('.chroma-detail-list').exists()).toBe(true) + expect(wrapper.text()).toContain('1536') + expect(wrapper.text()).toContain('2048') + expect(wrapper.text()).toContain('tenant') + }) +}) diff --git a/frontend/src/__tests__/console-context-menu-actions.test.ts b/frontend/src/__tests__/console-context-menu-actions.test.ts new file mode 100644 index 0000000..2ab1570 --- /dev/null +++ b/frontend/src/__tests__/console-context-menu-actions.test.ts @@ -0,0 +1,761 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { useAiChatStore } from '@/stores/ai-chat' +import { api } from '@/services/api' +import { clearRedisCommandDocsCache } from '@/modules/redis/command-docs' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_console' }, query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +const makeDatasource = (type: 'mysql' | 'redis' | 'dynamodb') => ({ + id: 'ds_console', + name: 'Console', + type, + host: 'localhost', + port: type === 'redis' ? 6379 : type === 'dynamodb' ? 0 : 3306, + username: '', + password: '', + database: '', + authSource: '', + options: type === 'dynamodb' ? { region: 'us-east-1' } : {}, +}) + +describe('Console context menu actions', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + clearRedisCommandDocsCache() + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + }) + + afterEach(() => { + clearRedisCommandDocsCache() + vi.restoreAllMocks() + }) + + it('shows redis context actions and copies selected command', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('SET user:1 ok') + + const input = editor.element as HTMLInputElement + const start = input.value.indexOf('SET user:1 ok') + input.selectionStart = start + input.selectionEnd = start + 'SET user:1 ok'.length + + await editor.trigger('contextmenu', { clientX: 140, clientY: 90 }) + await flushPromises() + + expect(wrapper.find('[data-testid="redis-cli-context-menu"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="redis-cli-context-ask-ai"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="redis-cli-context-execute"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="redis-cli-context-copy"]').exists()).toBe(true) + + await wrapper.get('[data-testid="redis-cli-context-copy"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('SET user:1 ok') + expect(store.notice.message).toBe('Command copied.') + }) + + it('opens AI quick prompt from redis context action', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('GET user:1') + await editor.trigger('contextmenu', { clientX: 60, clientY: 40 }) + await flushPromises() + + await wrapper.get('[data-testid="redis-cli-context-ask-ai"]').trigger('click') + await flushPromises() + + expect(wrapper.find('.ai-quick-prompt').exists()).toBe(true) + wrapper.unmount() + }) + + it('uses selected redis cli command as implicit statement for AI quick prompt', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + const turnSpy = vi.spyOn(api, 'aiChatTurn').mockResolvedValue({ + assistantMessage: 'mock', + } as any) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('GET user:1') + + const input = editor.element as HTMLInputElement + const start = input.value.indexOf('GET user:1') + input.selectionStart = start + input.selectionEnd = start + 'GET user:1'.length + + await editor.trigger('contextmenu', { clientX: 60, clientY: 40 }) + await flushPromises() + + await wrapper.get('[data-testid="redis-cli-context-ask-ai"]').trigger('click') + await flushPromises() + + await wrapper.find('.ai-quick-input input').setValue('explain this command') + await wrapper.find('.ai-quick-form').trigger('submit') + await flushPromises() + + expect(turnSpy).toHaveBeenCalled() + const payload = turnSpy.mock.calls[0]?.[0] as any + expect(payload?.implicitStatement).toBe('GET user:1') + wrapper.unmount() + }) + + it('does not append a Redis CLI result when a risky SET command is canceled', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValueOnce({ + riskInfo: { + action: 'warn', + level: 'medium', + reasons: ['write operation'], + }, + } as any) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('set pd:5 jjjjjj') + await editor.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.find('[data-testid="risk-danger-dialog"]').exists()).toBe(true) + await wrapper.get('.dialog-actions .btn.ghost').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(wrapper.text()).not.toContain('(nil)') + expect(wrapper.text()).not.toContain('set pd:5 jjjjjj') + wrapper.unmount() + }) + + it('shows Redis CLI command suggestions and applies the SET template', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('set') + await flushPromises() + + expect(wrapper.find('[data-testid="redis-cli-suggestions"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="redis-cli-suggestions"]').element.closest('#cli-log')).toBeNull() + await wrapper.get('[data-testid="redis-cli-suggestion-SET"]').trigger('mousedown') + await flushPromises() + + expect((editor.element as HTMLInputElement).value).toMatch(/^SET\s+key\s+value/) + wrapper.unmount() + }) + + it('ignores stale Redis command docs responses after switching datasources', async () => { + const store = useAppStore() + const slowDatasource = { ...makeDatasource('redis'), id: 'ds_console', name: 'Redis Slow' } + const fastDatasource = { ...makeDatasource('redis'), id: 'ds_fast', name: 'Redis Fast' } + store.datasources = [slowDatasource as any, fastDatasource as any] + + let resolveSlowDocs: (value: any) => void = () => {} + const slowDocs = new Promise((resolve) => { + resolveSlowDocs = resolve + }) + const docsSpy = vi.spyOn(api, 'getRedisCommandDocs').mockImplementation((id: string) => { + if (id === 'ds_console') return slowDocs + if (id === 'ds_fast') { + return Promise.resolve({ + updatedAt: 4_102_444_800_000, + commands: { + PING: { summary: 'Fast datasource ping command.' }, + }, + } as any) + } + return Promise.resolve({ updatedAt: 0, commands: {} } as any) + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + store.setCurrentDatasource(fastDatasource as any) + await flushPromises() + + resolveSlowDocs({ + updatedAt: 4_102_444_800_001, + commands: { + SET: { summary: 'Slow datasource SET command.' }, + }, + }) + await flushPromises() + + expect(docsSpy).toHaveBeenCalledWith('ds_console') + expect(docsSpy).toHaveBeenCalledWith('ds_fast') + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('s') + await flushPromises() + + expect(wrapper.find('[data-testid="redis-cli-suggestion-SET"]').exists()).toBe(false) + + await editor.setValue('p') + await flushPromises() + + expect(wrapper.find('[data-testid="redis-cli-suggestion-PING"]').exists()).toBe(true) + wrapper.unmount() + }) + + it('logs Redis CLI SET output only after the risk confirmation is approved', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + const executeSpy = vi.spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + riskInfo: { + action: 'warn', + level: 'medium', + reasons: ['write operation'], + }, + } as any) + .mockResolvedValueOnce({ + columns: ['result'], + rows: [{ result: 'OK' }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('set pd:5 jjjjjj') + await editor.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.find('[data-testid="risk-danger-dialog"]').exists()).toBe(true) + await wrapper.get('[data-testid="risk-danger-confirm"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[6]).toBe(true) + expect(wrapper.text()).toContain('OK') + expect(wrapper.text()).not.toContain('(nil)') + wrapper.unmount() + }) + + it('keeps Redis CLI output paired with commands during rapid submissions', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + let resolveFirst: (value: any) => void = () => {} + let resolveSecond: (value: any) => void = () => {} + const first = new Promise((resolve) => { + resolveFirst = resolve + }) + const second = new Promise((resolve) => { + resolveSecond = resolve + }) + const executeSpy = vi.spyOn(api, 'executeStatement').mockImplementation((_id: string, statement: string) => { + if (statement === 'get a') return first + if (statement === 'get b') return second + return Promise.resolve({ columns: ['result'], rows: [{ result: 'unexpected' }], rowCount: 1, elapsedMs: 1 } as any) + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('[data-testid="redis-cli-input"]') + await editor.setValue('get a') + await editor.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + await editor.setValue('get b') + await editor.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + + resolveFirst({ + columns: ['result'], + rows: [{ result: 'A' }], + rowCount: 1, + elapsedMs: 1, + }) + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + + resolveSecond({ + columns: ['result'], + rows: [{ result: 'B' }], + rowCount: 1, + elapsedMs: 1, + }) + await flushPromises() + + const groups = wrapper.findAll('#cli-lines > div') + expect(groups).toHaveLength(2) + expect(groups[0].text()).toContain('get a') + expect(groups[0].text()).toContain('A') + expect(groups[0].text()).not.toContain('B') + expect(groups[1].text()).toContain('get b') + expect(groups[1].text()).toContain('B') + expect(groups[1].text()).not.toContain('A') + wrapper.unmount() + }) + + it('keeps redis context menu within viewport when opened near bottom', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis') as any] + + const originalHeight = window.innerHeight + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 720, + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await wrapper.get('[data-testid="redis-cli-input"]').trigger('contextmenu', { + clientX: 140, + clientY: 708, + }) + await flushPromises() + + const menuEl = wrapper.get('[data-testid="redis-cli-context-menu"]').element as HTMLElement + const top = Number.parseInt(menuEl.style.top || '0', 10) + expect(top).toBeLessThan(708) + expect(top).toBeGreaterThanOrEqual(8) + + wrapper.unmount() + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: originalHeight, + }) + }) + + it('shows SQL editor context actions and executes from context menu', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['value'], + rows: [{ value: 1 }], + rowCount: 1, + elapsedMs: 1, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT 1;') + await editor.trigger('contextmenu', { clientX: 120, clientY: 70 }) + await flushPromises() + + expect(wrapper.find('[data-testid="statement-context-menu"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="statement-context-ask-ai"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="statement-context-execute"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="statement-context-copy"]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('Format Query') + + await wrapper.get('[data-testid="statement-context-execute"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + const executed = String(executeSpy.mock.calls[0]?.[1] || '') + expect(executed.toUpperCase()).toContain('SELECT 1') + }) + + it('copies selected SQL statement when context menu opens with a selection', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT * FROM users;\nSELECT * FROM orders;') + + const input = editor.element as HTMLTextAreaElement + const selected = 'SELECT * FROM orders' + const start = input.value.indexOf(selected) + input.selectionStart = start + input.selectionEnd = start + selected.length + + await editor.trigger('contextmenu', { clientX: 120, clientY: 70 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-copy"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith(selected) + wrapper.unmount() + }) + + it('uses selected SQL range as pending context for Ask with AI', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + const aiStore = useAiChatStore() + aiStore.setPendingContext('SELECT stale_context') + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT * FROM users;\nUPDATE users SET name = "alice" WHERE id = 1;') + + const input = editor.element as HTMLTextAreaElement + const selected = 'UPDATE users SET name = "alice"' + const start = input.value.indexOf(selected) + input.selectionStart = start + input.selectionEnd = start + selected.length + + await editor.trigger('contextmenu', { clientX: 120, clientY: 125 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-ask-ai"]').trigger('click') + await flushPromises() + + expect(aiStore.pendingContext).toBe(selected) + wrapper.unmount() + }) + + it('copies SQL statement on mouse line when context menu opens without selection', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT * FROM users;\nSELECT * FROM orders;') + + const input = editor.element as HTMLTextAreaElement + input.selectionStart = 0 + input.selectionEnd = 0 + Object.defineProperty(input, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 40, + y: 100, + left: 40, + top: 100, + width: 400, + height: 120, + right: 440, + bottom: 220, + toJSON: () => null, + }) as DOMRect, + }) + + await editor.trigger('contextmenu', { clientX: 120, clientY: 125 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-copy"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('SELECT * FROM orders') + wrapper.unmount() + }) + + it('uses SQL statement on mouse line as pending context when Ask with AI opens without selection', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + const aiStore = useAiChatStore() + aiStore.setPendingContext('SELECT stale_context') + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT * FROM users;\nUPDATE users SET name = "alice" WHERE id = 1;') + + const input = editor.element as HTMLTextAreaElement + input.selectionStart = 0 + input.selectionEnd = 0 + Object.defineProperty(input, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 40, + y: 100, + left: 40, + top: 100, + width: 400, + height: 120, + right: 440, + bottom: 220, + toJSON: () => null, + }) as DOMRect, + }) + + await editor.trigger('contextmenu', { clientX: 120, clientY: 125 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-ask-ai"]').trigger('click') + await flushPromises() + + expect(aiStore.pendingContext).toBe('UPDATE users SET name = "alice" WHERE id = 1') + wrapper.unmount() + }) + + it('copies indented SQL statement on mouse line when context menu opens without selection', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue('SELECT * FROM users;\n SELECT * FROM orders;') + + const input = editor.element as HTMLTextAreaElement + input.selectionStart = 0 + input.selectionEnd = 0 + Object.defineProperty(input, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 40, + y: 100, + left: 40, + top: 100, + width: 400, + height: 120, + right: 440, + bottom: 220, + toJSON: () => null, + }) as DOMRect, + }) + + await editor.trigger('contextmenu', { clientX: 120, clientY: 125 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-copy"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('SELECT * FROM orders') + wrapper.unmount() + }) + + it('clears pending context when Ask with AI opens on an empty editor statement', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + const aiStore = useAiChatStore() + aiStore.setPendingContext('SELECT stale_context') + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + await editor.setValue(' \n ') + + const input = editor.element as HTMLTextAreaElement + input.selectionStart = 0 + input.selectionEnd = 0 + Object.defineProperty(input, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 40, + y: 100, + left: 40, + top: 100, + width: 400, + height: 120, + right: 440, + bottom: 220, + toJSON: () => null, + }) as DOMRect, + }) + + await editor.trigger('contextmenu', { clientX: 120, clientY: 125 }) + await flushPromises() + await wrapper.get('[data-testid="statement-context-ask-ai"]').trigger('click') + await flushPromises() + + expect(aiStore.pendingContext).toBeNull() + wrapper.unmount() + }) + + it('sends dynamodb statement and datasource page context when asking AI from sql editor context menu', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('dynamodb') as any] + const aiStore = useAiChatStore() + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = wrapper.get('.console-monaco-editor__fallback') + const selected = "SELECT * FROM \"orders\" WHERE \"pk\" = 'USER#1'" + await editor.setValue(`${selected};`) + + const input = editor.element as HTMLTextAreaElement + const start = input.value.indexOf(selected) + input.selectionStart = start + input.selectionEnd = start + selected.length + + await editor.trigger('contextmenu', { clientX: 120, clientY: 90 }) + await flushPromises() + + await wrapper.get('[data-testid="statement-context-ask-ai"]').trigger('click') + await flushPromises() + + expect(aiStore.pendingContext).toBe(selected) + expect(aiStore.autoSend).toBe(true) + expect(aiStore.isOpen).toBe(true) + expect(aiStore.pendingPageContext).toEqual({ + currentDatasourceId: 'ds_console', + currentDatasourceType: 'dynamodb', + currentDatabase: '', + currentEntity: '', + currentStatement: `${selected};`, + }) + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/console-context-menu-i18n.test.ts b/frontend/src/__tests__/console-context-menu-i18n.test.ts new file mode 100644 index 0000000..8b1bc9f --- /dev/null +++ b/frontend/src/__tests__/console-context-menu-i18n.test.ts @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { afterEach, describe, expect, it } from 'vitest' +import ConsoleStatementContextMenu from '@/views/console/components/ConsoleStatementContextMenu.vue' +import { getAppLocale, resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const baseProps = { + visible: true, + x: 120, + y: 80, + hasSelection: true, + hasContent: true, + canExecute: true, +} + +afterEach(() => { + resetAppI18nForTest() +}) + +describe('console context menu i18n', () => { + it('renders context menu wording in zh when app locale is zh', async () => { + setAppLocale('zh') + + const wrapper = mount(ConsoleStatementContextMenu, { + props: baseProps, + attachTo: document.body, + }) + + expect(wrapper.text()).toContain(tApp('context.askAi')) + expect(wrapper.text()).toContain(tApp('context.executeSelection')) + expect(wrapper.text()).toContain(tApp('context.copySnippet')) + + await wrapper.get('.relative.group').trigger('mouseenter') + await nextTick() + + expect(wrapper.text()).toContain(tApp('context.aiSuggestions')) + expect(wrapper.get('[data-testid="statement-context-ask-ai-explain-logic"]').text()).toContain( + tApp('context.explainLogic'), + ) + + wrapper.unmount() + }) + + it('prefers app locale over document language for console explain wording', () => { + setAppLocale('zh') + document.documentElement.lang = 'en' + + expect(getAppLocale()).toBe('zh') + expect(tApp('explain.title')).toBe('执行计划') + + setAppLocale('en') + document.documentElement.lang = 'zh-CN' + + expect(getAppLocale()).toBe('en') + expect(tApp('explain.title')).toBe('Explain Plan') + }) +}) diff --git a/frontend/src/__tests__/console-d1-execution-mode-propagation.test.ts b/frontend/src/__tests__/console-d1-execution-mode-propagation.test.ts new file mode 100644 index 0000000..9789567 --- /dev/null +++ b/frontend/src/__tests__/console-d1-execution-mode-propagation.test.ts @@ -0,0 +1,147 @@ +import { computed, defineComponent, h, ref } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import type { ExplainResult, QueryResult } from '@/types' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { useSqlPaging } from '@/views/console/composables/useSqlPaging' +import { useMultiResults } from '@/views/console/composables/useMultiResults' + +describe('Console D1 execution mode propagation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes execution mode for SQL paging follow-up requests', async () => { + const store = useAppStore() + store.current = { + id: 'ds_d1', + name: 'D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any + store.mongoDatabase = '' + + const statement = ref('SELECT * FROM logs') + const result = ref({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 10, + } as QueryResult) + const resultRows = computed(() => result.value?.rows || []) + const resultMeta = ref('') + const statusMessage = ref('') + const statusType = ref('') + const explainResult = ref(null) + + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 2 }], + rowCount: 1, + elapsedMs: 12, + } as QueryResult) + + const Harness = defineComponent({ + setup(_, { expose }) { + const paging = useSqlPaging({ + statement, + result, + resultRows, + resultMeta, + statusMessage, + statusType, + explainResult, + isSQL: computed(() => true), + renderTable: computed(() => true), + resultShell: ref(null), + virtualTableRef: ref(null), + markActive: vi.fn(), + isD1: computed(() => true), + d1ExecutionMode: ref<'dev' | 'remote'>('dev'), + } as any) + expose({ paging }) + return () => h('div') + }, + }) + + const wrapper = mount(Harness) + const paging = (wrapper.vm as any).paging + + paging.sqlPagingActive.value = true + paging.sqlHasNext.value = true + paging.sqlPagingSource.value = 'SELECT * FROM logs' + paging.sqlPagingNextToken.value = 'next-token' + paging.sqlPageSize.value = 200 + + await paging.loadNextSqlPage() + + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(executeSpy.mock.calls[0]?.[5]).toBe('dev') + wrapper.unmount() + }) + + it('passes execution mode for multi-statement SQL execution', async () => { + const store = useAppStore() + store.current = { + id: 'ds_d1', + name: 'D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any + store.mongoDatabase = '' + + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 10, + } as QueryResult) + + const multi = useMultiResults({ + result: ref(null), + resultMeta: ref(''), + statusMessage: ref(''), + statusType: ref(''), + explainResult: ref(null), + sqlPageSize: ref(200), + mongoQueryPageSize: 50, + mongoDatabaseMode: computed(() => false), + isSQL: computed(() => true), + isMongo: computed(() => false), + isRedis: computed(() => false), + truncateText: (value: string) => value, + runStatement: vi.fn(async () => {}), + addHistory: vi.fn(async () => {}), + loadEntities: vi.fn(async () => {}), + resetSqlPaging: vi.fn(), + resetMongoPaging: vi.fn(), + isD1: computed(() => true), + d1ExecutionMode: ref<'dev' | 'remote'>('dev'), + } as any) + + await multi.executeAllCommands(['SELECT 1', 'SELECT 2']) + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[0]?.[5]).toBe('dev') + expect(executeSpy.mock.calls[1]?.[5]).toBe('dev') + }) +}) diff --git a/frontend/src/__tests__/console-datasource-dropdown-switch.test.ts b/frontend/src/__tests__/console-datasource-dropdown-switch.test.ts new file mode 100644 index 0000000..ca378e2 --- /dev/null +++ b/frontend/src/__tests__/console-datasource-dropdown-switch.test.ts @@ -0,0 +1,2513 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NavigationFailureType, createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getDatasourceTypeIconUrl } from '@/modules/datasource/icons' + +const Dummy = { template: '
' } + +const makeDatasource = (id: string, name: string, type: string, host: string, port: number, extras: Record = {}) => ({ + id, + name, + type, + host, + port, + ...extras, +}) + +const getStatementEditorInput = (wrapper: ReturnType) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} + +const setEntityListScroll = async ( + wrapper: ReturnType, + opts: { scrollTop: number; clientHeight: number; scrollHeight: number }, +) => { + const listEl = wrapper.find('#entity-list').element as HTMLElement + Object.defineProperty(listEl, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true }) + Object.defineProperty(listEl, 'clientHeight', { value: opts.clientHeight, configurable: true }) + Object.defineProperty(listEl, 'scrollHeight', { value: opts.scrollHeight, configurable: true }) + await wrapper.find('#entity-list').trigger('scroll') + await flushPromises() +} + +const createDragDataTransfer = () => ({ + dropEffect: 'move', + effectAllowed: 'move', + files: [], + items: [], + types: [], + clearData: vi.fn(), + getData: vi.fn(() => ''), + setData: vi.fn(), + setDragImage: vi.fn(), +}) + +const stubHorizontalRect = (el: Element, left: number, width = 120) => { + Object.defineProperty(el, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: left, + y: 0, + top: 0, + left, + width, + height: 32, + right: left + width, + bottom: 32, + toJSON: () => ({}), + }), + }) +} + +describe('Console datasource dropdown switch', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + ], + }) + + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await router.isReady() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('shows only connected datasources in the toolbar dropdown and removes top refresh button', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + makeDatasource('ds_mongo', 'Analytics', 'mongodb', '10.0.2.303', 27017) as any, + makeDatasource('ds_redis_cluster', 'Cache Cluster', 'redis_cluster', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + store.status['ds_mongo'] = 'failed' + store.status['ds_redis_cluster'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_redis') { + return ['sample_key', 'sample_key_2'] as any + } + return [] as any + }) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + await flushPromises() + await flushPromises() + + const topRefreshButton = wrapper.findAll('.list-toolbar .btn').find((button) => button.text() === 'Refresh Entities') + expect(topRefreshButton).toBeUndefined() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + + const options = wrapper.findAll('[data-testid="console-datasource-dropdown-option"]') + const labels = options.map((option) => option.text()) + + expect(options).toHaveLength(3) + expect(labels.some((label) => label.includes('MySQL - Production'))).toBe(true) + expect(labels.some((label) => label.includes('PostgreSQL - Staging'))).toBe(true) + expect(labels.some((label) => label.includes('Redis - Cache Cluster'))).toBe(true) + expect(labels.some((label) => label.includes('MongoDB - Analytics'))).toBe(false) + }) + + it('switches datasource by creating a new query session and restores the old datasource when returning to the old tab', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockImplementation(async () => ['sample_key', 'sample_key_2'] as any) + const listEntitiesPageSpy = vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT 42 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('42') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(listEntitiesPageSpy.mock.calls.some((call) => call[0] === 'ds_pg')).toBe(true) + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(2) + expect(tabs()[0]!.text()).toContain('Query 1') + expect(tabs()[0]!.attributes('title')).toContain('Production') + expect(tabs()[0]!.get('[data-testid="statement-tab-datasource-icon"]').attributes('src')).toBe( + getDatasourceTypeIconUrl('mysql'), + ) + expect(tabs()[1]!.text()).toContain('Query 2') + expect(tabs()[1]!.attributes('title')).toContain('Staging') + expect(tabs()[1]!.get('[data-testid="statement-tab-datasource-icon"]').attributes('src')).toBe( + getDatasourceTypeIconUrl('postgresql'), + ) + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toBe('') + expect(wrapper.text()).not.toContain('42') + + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 42 AS answer;') + expect(wrapper.text()).toContain('42') + }) + + it('keeps datasource routing and session restore correct after cross-datasource tab reorder', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 42 AS answer;') + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 7 AS answer;') + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + const tabLabels = () => tabs().map((tab) => tab.attributes('title')) + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(tabLabels()[0]).toContain('Production') + expect(tabLabels()[1]).toContain('Staging') + + const dragData = createDragDataTransfer() + const currentTabs = tabs() + stubHorizontalRect(currentTabs[0]!.element, 0) + stubHorizontalRect(currentTabs[1]!.element, 140) + + await currentTabs[0]!.trigger('dragstart', { dataTransfer: dragData }) + await currentTabs[1]!.trigger('dragover', { dataTransfer: dragData, clientX: 240 }) + await currentTabs[1]!.trigger('drop', { dataTransfer: dragData, clientX: 240 }) + await currentTabs[0]!.trigger('dragend', { dataTransfer: dragData }) + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(tabLabels()[0]).toContain('Staging') + expect(tabLabels()[1]).toContain('Production') + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 7 AS answer;') + + await new Promise((resolve) => setTimeout(resolve, 0)) + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 42 AS answer;') + }) + + it('restores the left entity filter and filtered entities when switching back to a datasource from the dropdown', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string, pattern: string) => { + const items = datasourceId === 'ds_pg' + ? ['public.customers', 'public.invoices'] + : ['orders', 'users'] + const keyword = String(pattern || '').trim().toLowerCase() + return { + items: keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) : items, + cursor: '', + done: true, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + const entityEntries = () => wrapper.findAll('.entity-entry').map((node) => node.text()) + + await flushPromises() + await flushPromises() + + const entityFilter = wrapper.get('#entity-pattern') + await entityFilter.setValue('ord') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('ord') + expect(wrapper.text()).toContain('orders') + expect(wrapper.text()).not.toContain('users') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(wrapper.findAll('[data-testid="statement-tab"]')).toHaveLength(2) + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('ord') + expect(wrapper.text()).toContain('orders') + expect(wrapper.text()).not.toContain('users') + }) + + it('restores the last active tab for a datasource when returning from the dropdown', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.0.102', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_redis') { + return ['order_summary:1', 'order_summary:2'] as any + } + return [] as any + }) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (_datasourceId: string, pattern: string) => { + const items = ['fd_campaign', 'fd_support_ticket', 'fd_support_ticket_message'] + const keyword = String(pattern || '').trim().toLowerCase() + return { + items: keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) : items, + cursor: '', + done: true, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + const entityEntries = () => wrapper.findAll('.entity-entry').map((node) => node.text()) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 1 AS first_tab;') + await wrapper.get('#entity-pattern').setValue('fd_campaign') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 2 AS second_tab;') + await wrapper.get('#entity-pattern').setValue('fd_support') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(2) + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(tabs()).toHaveLength(3) + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 2 AS second_tab;') + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + }) + + it('restores the Elasticsearch local filter and filtered indices when switching back from the dropdown', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_es', 'Search', 'elasticsearch', '10.0.0.111', 9200) as any, + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + ] + store.status['ds_es'] = 'connected' + store.status['ds_mysql'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_es' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'executeStatement').mockImplementation(async (datasourceId: string, statement: string) => { + if (datasourceId === 'ds_es' && statement.includes('_cat/indices')) { + return { + columns: [], + rows: [ + { index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }, + { index: 'futrixdata-demo-2', health: 'yellow', 'store.size': '15mb' }, + { index: 'logs-prod-2026', health: 'green', 'store.size': '24mb' }, + ], + rowCount: 3, + elapsedMs: 8, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 1, + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const entityFilter = wrapper.get('#entity-pattern') + await entityFilter.setValue('demo') + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('demo') + expect(wrapper.text()).toContain('futrixdata-demo-1') + expect(wrapper.text()).not.toContain('logs-prod-2026') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + expect(wrapper.findAll('[data-testid="statement-tab"]')).toHaveLength(2) + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('demo') + expect(wrapper.text()).toContain('futrixdata-demo-1') + expect(wrapper.text()).not.toContain('logs-prod-2026') + expect(wrapper.text()).not.toContain('No entities found.') + }) + + it('reloads uncached Elasticsearch entities when returning to an existing tab after the first load was interrupted', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_es', 'Search', 'elasticsearch', '10.0.0.111', 9200) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_es'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + + let resolveFirstEsLoad: ((value: any) => void) | null = null + const executeStatementSpy = vi.spyOn(api, 'executeStatement').mockImplementation(async (datasourceId: string, statement: string) => { + if (datasourceId === 'ds_es' && statement.includes('_cat/indices')) { + if (!resolveFirstEsLoad) { + return await new Promise((resolve) => { + resolveFirstEsLoad = resolve + }) + } + return { + columns: [], + rows: [ + { index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }, + { index: 'logs-prod-2026', health: 'yellow', 'store.size': '24mb' }, + ], + rowCount: 2, + elapsedMs: 8, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 1, + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + + resolveFirstEsLoad?.({ + columns: [], + rows: [ + { index: 'stale-es-index', health: 'green', 'store.size': '8mb' }, + ], + rowCount: 1, + elapsedMs: 8, + }) + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + const esEntityLoadCalls = executeStatementSpy.mock.calls.filter( + ([datasourceId, statement]) => datasourceId === 'ds_es' && String(statement || '').includes('_cat/indices'), + ) + + expect(router.currentRoute.value.params.id).toBe('ds_es') + expect(esEntityLoadCalls).toHaveLength(2) + expect(wrapper.text()).toContain('futrixdata-demo-1') + expect(wrapper.text()).toContain('logs-prod-2026') + expect(wrapper.text()).not.toContain('stale-es-index') + expect(wrapper.text()).not.toContain('No entities found.') + }) + + it('restores MySQL filtered entities when returning from Redis via the dropdown', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.0.102', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockImplementation(async (datasourceId: string, pattern: string) => { + if (datasourceId !== 'ds_redis') return [] as any + const items = ['order_summary:1', 'order_summary:2', 'billing:1'] + const keyword = String(pattern || '').trim().toLowerCase() + return keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) as any : items as any + }) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (_datasourceId: string, pattern: string) => { + const items = ['fd_campaign', 'fd_support_ticket', 'fd_support_ticket_message'] + const keyword = String(pattern || '').trim().toLowerCase() + return { + items: keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) : items, + cursor: '', + done: true, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + const entityEntries = () => wrapper.findAll('.entity-entry').map((node) => node.text()) + + await flushPromises() + await flushPromises() + + await wrapper.get('#entity-pattern').setValue('fd_support') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + expect(wrapper.text()).not.toContain('No entities found.') + }) + + it('reloads server-filtered entities when restoring another tab on the same datasource', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + ] + store.status['ds_mysql'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (_datasourceId: string, pattern: string) => { + const items = ['fd_campaign', 'fd_support_ticket', 'fd_support_ticket_message'] + const keyword = String(pattern || '').trim().toLowerCase() + return { + items: keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) : items, + cursor: '', + done: true, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + const entityEntries = () => wrapper.findAll('.entity-entry').map((node) => node.text()) + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + + await flushPromises() + await flushPromises() + + await wrapper.get('#entity-pattern').setValue('fd_support') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(tabs()).toHaveLength(2) + + await wrapper.get('#entity-pattern').setValue('campaign') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('campaign') + expect(entityEntries()).toContain('fd_campaign') + expect(entityEntries()).not.toContain('fd_support_ticket') + + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('fd_support') + expect(entityEntries()).toContain('fd_support_ticket') + expect(entityEntries()).not.toContain('fd_campaign') + expect(wrapper.text()).not.toContain('No entities found.') + }) + + it('restores paged entity cursors when returning to a cached datasource tab', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + const listEntitiesPageSpy = vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string, _pattern: string, _database: string, cursor: string) => { + if (datasourceId === 'ds_pg') { + if (cursor === 'pg-cursor') { + return { items: ['pg_page_2'], cursor: '', done: true } as any + } + return { items: ['pg_page_1'], cursor: 'pg-cursor', done: false } as any + } + if (cursor === 'mysql-cursor') { + return { items: ['mysql_page_2'], cursor: '', done: true } as any + } + return { items: ['mysql_page_1'], cursor: 'mysql-cursor', done: false } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.text()).toContain('mysql_page_1') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(wrapper.text()).toContain('pg_page_1') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(wrapper.text()).toContain('mysql_page_1') + + listEntitiesPageSpy.mockClear() + + await setEntityListScroll(wrapper, { scrollTop: 900, clientHeight: 200, scrollHeight: 1000 }) + + expect(listEntitiesPageSpy).toHaveBeenCalledTimes(1) + expect(listEntitiesPageSpy.mock.calls[0]).toEqual(['ds_mysql', '', '', 'mysql-cursor', 200, '', false]) + expect(wrapper.text()).toContain('mysql_page_2') + }) + + it('preserves the original tab datasource binding across direct route changes', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + vi.spyOn(api, 'executeStatement').mockImplementation(async (datasourceId: string) => ({ + columns: ['answer'], + rows: [{ answer: datasourceId === 'ds_pg' ? 7 : 42 }], + rowCount: 1, + elapsedMs: 1, + }) as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 42 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('42') + + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(tabs()).toHaveLength(2) + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toBe('') + expect(wrapper.text()).not.toContain('42') + + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(tabs()).toHaveLength(2) + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 42 AS answer;') + expect(wrapper.text()).toContain('42') + }) + + it('does not restore a stale tab snapshot when routing to an invalid datasource id', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 42 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('42') + + await router.push({ name: 'console', params: { id: 'ds_missing' } }) + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + + expect(router.currentRoute.value.params.id).toBe('ds_missing') + expect(store.current).toBeNull() + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toBe('') + expect(wrapper.text()).not.toContain('42') + expect(tabs()).toHaveLength(1) + expect(tabs().filter((tab) => tab.attributes('aria-selected') === 'true')).toHaveLength(0) + + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(tabs()).toHaveLength(1) + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 42 AS answer;') + expect(wrapper.text()).toContain('42') + }) + + it('rolls back provisional tab state when datasource navigation resolves with a navigation failure', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const originalPush = router.push.bind(router) + vi.spyOn(router, 'push').mockImplementation(async (location: any) => { + const targetId = String(location?.params?.id || '') + if (targetId === 'ds_pg') { + return { + type: NavigationFailureType.aborted, + to: router.resolve(location), + from: router.currentRoute.value, + } as any + } + return originalPush(location) + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 42 AS answer;') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(wrapper.findAll('[data-testid="statement-tab"]')).toHaveLength(1) + + await getStatementEditorInput(wrapper).setValue('SELECT 7 AS answer;') + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await wrapper.findAll('[data-testid="statement-tab"]')[0]!.trigger('click') + await flushPromises() + + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 7 AS answer;') + }) + + it('restores the pre-close active tab when closing across datasources hits a navigation failure', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const originalPush = router.push.bind(router) + vi.spyOn(router, 'push').mockImplementation(async (location: any) => { + const targetId = String(location?.params?.id || '') + if (targetId === 'ds_mysql' && router.currentRoute.value.params.id === 'ds_pg') { + return { + type: NavigationFailureType.aborted, + to: router.resolve(location), + from: router.currentRoute.value, + } as any + } + return originalPush(location) + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + + await getStatementEditorInput(wrapper).setValue('SELECT 7 AS answer;') + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + const activeTabIndex = tabs().findIndex((tab) => tab.attributes('aria-selected') === 'true') + expect(activeTabIndex).toBe(1) + + await tabs()[activeTabIndex]!.get('[data-testid="statement-tab-close"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(tabs()).toHaveLength(2) + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 7 AS answer;') + }) + + it('does not roll back the later successful switch when an earlier navigation resolves as cancelled', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_pg', 'Staging', 'postgresql', '10.0.1.202', 5432) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_pg'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_pg') { + return { items: ['public.customers'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_pg"]').trigger('click') + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(2) + expect(router.currentRoute.value.params.id).toBe('ds_pg') + + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + + const originalPush = router.push.bind(router) + let pgPushCount = 0 + let resolveCancelledPush: ((value: any) => void) | null = null + + vi.spyOn(router, 'push').mockImplementation(async (location: any) => { + const targetId = String(location?.params?.id || '') + if (targetId !== 'ds_pg') { + return originalPush(location) + } + pgPushCount += 1 + if (pgPushCount === 1) { + return new Promise((resolve) => { + resolveCancelledPush = resolve + }) + } + const result = await originalPush(location) + resolveCancelledPush?.({ + type: NavigationFailureType.cancelled, + to: router.resolve(location), + from: router.currentRoute.value, + } as any) + resolveCancelledPush = null + return result + }) + + const firstClick = tabs()[1]!.trigger('click') + await Promise.resolve() + const secondClick = tabs()[1]!.trigger('click') + + await secondClick + await firstClick + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_pg') + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect(tabs()[0]!.attributes('aria-selected')).toBe('false') + }) + + it('restores redis console session state when returning to a redis query tab', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['sample_key'], cursor: '', done: true } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_datasourceId: string, name: string) => { + if (name === 'sample_key') { + return { + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'short preview', + truncated: false, + }, + } as any + } + return { + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any + }) + vi.spyOn(api, 'executeStatement').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_redis') { + return { + columns: ['result'], + rows: [{ result: 'PONG' }], + rowCount: 1, + elapsedMs: 1, + } as any + } + return { + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect(wrapper.find('[data-testid="redis-proto-shell"]').exists()).toBe(true) + + await wrapper.get('[data-testid="redis-key-search"]').setValue('sample') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + const redisKey = wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes('sample_key')) + expect(redisKey).toBeTruthy() + await redisKey!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="raw"]').trigger('click') + await flushPromises() + + const cliInput = wrapper.get('[data-testid="redis-cli-input"]') + await cliInput.setValue('PING') + await cliInput.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.text()).toContain('redis>PING') + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect((wrapper.get('[data-testid="redis-key-search"]').element as HTMLInputElement).value).toBe('sample') + expect(wrapper.get('#active-key-title').text()).toContain('sample_key') + expect(wrapper.get('#active-key-type').text()).toContain('STR') + expect(wrapper.get('#stat-ttl').text()).toContain('892s') + expect(wrapper.get('[data-tab="raw"]').attributes('class')).toContain('font-bold') + expect(wrapper.text()).toContain('redis>PING') + }) + + it('preserves redis tree expansion when restoring a tab with a saved search', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'scanRedisKeys').mockImplementation(async (_datasourceId: string, pattern: string) => { + if (pattern === 'group:*') { + return { keys: ['group:alpha', 'group:beta'], cursor: '', done: true } as any + } + return { keys: ['group:alpha', 'group:beta'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('group') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + const findRedisRow = (name: string) => + wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes(name)) + + expect(findRedisRow('group')).toBeTruthy() + await findRedisRow('group')!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + expect(findRedisRow('alpha')).toBeTruthy() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect((wrapper.get('[data-testid="redis-key-search"]').element as HTMLInputElement).value).toBe('group') + expect(findRedisRow('alpha')).toBeTruthy() + }) + + it('keeps the first mongodb filter responsive after switching between blank redis tabs', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mongo', 'Analytics', 'mongodb', '10.0.2.303', 27017) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mongo'] = 'connected' + store.status['ds_redis'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_mongo' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + const listDatabasesSpy = vi.spyOn(api, 'listDatabases').mockImplementation(async (_datasourceId: string, pattern: string) => { + const items = ['analytics', 'archive'] + const keyword = String(pattern || '').trim().toLowerCase() + if (!keyword) return items as any + return items.filter((name) => name.includes(keyword)) as any + }) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ + keys: ['alpha', 'beta'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(3) + + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + + await tabs()[2]!.trigger('click') + await flushPromises() + await flushPromises() + + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_mongo') + + listDatabasesSpy.mockClear() + + await wrapper.get('#entity-pattern').setValue('anal') + await flushPromises() + await flushPromises() + + expect(listDatabasesSpy).toHaveBeenCalledWith('ds_mongo', 'anal') + expect(wrapper.text()).toContain('analytics') + }) + + it('reloads mongodb collections when restoring a tab with a different saved filter', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mongo', 'Analytics', 'mongodb', '10.0.2.303', 27017, { database: 'analytics' }) as any, + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + ] + store.status['ds_mongo'] = 'connected' + store.status['ds_mysql'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_mongo' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + const listEntitiesSpy = vi.spyOn(api, 'listEntities').mockImplementation(async (datasourceId: string, pattern: string) => { + if (datasourceId !== 'ds_mongo') return [] as any + const items = ['orders', 'order_items', 'customers'] + const keyword = String(pattern || '').trim().toLowerCase() + return keyword ? items.filter((item) => item.toLowerCase().includes(keyword)) as any : items as any + }) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + const entityEntries = () => wrapper.findAll('.entity-entry').map((node) => node.text()) + + await flushPromises() + await flushPromises() + + await wrapper.get('#entity-pattern').setValue('order') + await flushPromises() + await flushPromises() + + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('order') + expect(entityEntries()).toContain('orders') + expect(entityEntries()).toContain('order_items') + expect(entityEntries()).not.toContain('customers') + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + await wrapper.get('#entity-pattern').setValue('cust') + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(2) + expect(tabs()[1]!.attributes('aria-selected')).toBe('true') + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('cust') + expect(entityEntries()).toContain('customers') + expect(entityEntries()).not.toContain('orders') + + listEntitiesSpy.mockClear() + + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(listEntitiesSpy).toHaveBeenCalledWith('ds_mongo', 'order', 'analytics', '', false) + expect((wrapper.get('#entity-pattern').element as HTMLInputElement).value).toBe('order') + expect(entityEntries()).toContain('orders') + expect(entityEntries()).toContain('order_items') + expect(entityEntries()).not.toContain('customers') + }) + + it('clears stale redis viewer-tab restores after switching to a different key', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ + keys: ['sample_key', 'other_key'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_datasourceId: string, name: string) => { + if (name === 'sample_key') { + return { + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: '{\"answer\":42}', + truncated: false, + }, + } as any + } + if (name === 'other_key') { + return { + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '120s' }, + { label: 'Size', value: 32 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'plain-text-value', + truncated: false, + }, + } as any + } + return { + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + const findRedisKey = (name: string) => + wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes(name)) + + await findRedisKey('sample_key')!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + expect(wrapper.get('[data-tab="json"]').attributes('class')).toContain('font-bold') + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + await findRedisKey('sample_key')!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + await wrapper.get('[data-tab="raw"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-tab="raw"]').attributes('class')).toContain('font-bold') + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + + await tabs()[2]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(wrapper.get('[data-tab="raw"]').attributes('class')).toContain('font-bold') + + await findRedisKey('other_key')!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + await findRedisKey('sample_key')!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + expect(wrapper.get('[data-tab="json"]').attributes('class')).toContain('font-bold') + expect(wrapper.get('[data-tab="raw"]').attributes('class')).not.toContain('font-bold') + }) + + it('initializes redis keys when switching into a fresh redis query tab', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + const scanRedisKeysSpy = vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ + keys: ['sample_key', 'sample_key_2'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect(wrapper.find('[data-testid="redis-proto-shell"]').exists()).toBe(true) + expect(scanRedisKeysSpy).toHaveBeenCalledWith('ds_redis', '*', '') + expect(wrapper.text()).toContain('sample_key') + expect(wrapper.text()).toContain('sample_key_2') + }) + + it('reloads redis keys when restoring a saved search before the debounce finishes', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + const scanRedisKeysSpy = vi.spyOn(api, 'scanRedisKeys').mockImplementation(async (_datasourceId: string, pattern: string) => { + if (pattern === '*user*') { + return { keys: ['user:001', 'user:002'], cursor: '', done: true } as any + } + return { keys: ['jobs:001', 'user:001', 'user:002'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + const findRedisRow = (name: string) => + wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes(name)) + + expect(findRedisRow('jobs')).toBeTruthy() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('user') + await flushPromises() + await flushPromises() + + const viewState = (wrapper.vm as any).$?.setupState?.ctx + expect(viewState).toBeTruthy() + const redisTab = viewState.statementTabs.value.find((tab: any) => tab.id === viewState.activeStatementTabId.value) + expect(redisTab).toBeTruthy() + expect(String(redisTab.redisState?.keySearch || '')).toBe('user') + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + await tabs()[0]!.trigger('click') + await flushPromises() + await flushPromises() + + await tabs()[1]!.trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect((wrapper.get('[data-testid="redis-key-search"]').element as HTMLInputElement).value).toBe('user') + expect(scanRedisKeysSpy.mock.calls.some((call) => call[0] === 'ds_redis' && call[1] === '*user*')).toBe(true) + expect(findRedisRow('user')).toBeTruthy() + }) + + it('keeps query tabs visible when switching from mysql to elasticsearch', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_es', 'Search', 'elasticsearch', '10.0.4.20', 9200) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_es'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_es') { + return { items: ['logs-app'], cursor: '', done: true } as any + } + return { items: ['orders'], cursor: '', done: true } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'keyword', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + const tabs = wrapper.findAll('[data-testid="statement-tab"]') + expect(router.currentRoute.value.params.id).toBe('ds_es') + expect(wrapper.findComponent({ name: 'ConsoleElasticDslWorkspace' }).exists()).toBe(true) + expect(tabs).toHaveLength(2) + expect(tabs[1]?.text()).toContain('Query 2') + expect(wrapper.find('[data-testid="statement-tab-add"]').exists()).toBe(true) + }) + + it('preserves elasticsearch field uncheck state when switching away and back from the datasource dropdown', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_es', 'Search', 'elasticsearch', '10.0.4.20', 9200) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_es'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'executeStatement').mockImplementation(async (_datasourceId: string, statement: string) => { + if ((statement || '').includes('/_cat/indices?format=json')) { + return { + columns: [], + rows: [{ index: 'logs-app', health: 'green', 'store.size': '12mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any + }) + vi.spyOn(api, 'describeEntity').mockImplementation(async (datasourceId: string, name: string) => { + if (datasourceId === 'ds_es' && name === 'logs-app') { + return { + columns: [ + { name: 'title', dataType: 'text', nullable: '-' }, + { name: 'body', dataType: 'text', nullable: '-' }, + { name: 'user.id', dataType: 'keyword', nullable: '-' }, + ], + indexes: [], + details: [], + } as any + } + return { + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + await wrapper.get('.entity-item').trigger('click') + await flushPromises() + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + const findElasticFieldCheckbox = (fieldName: string) => { + const row = wrapper.findAll('.es-index-field-item').find((item) => item.text().includes(fieldName)) + expect(row).toBeTruthy() + return row!.get('input[type="checkbox"]') + } + + const bodyCheckbox = findElasticFieldCheckbox('body') + expect((bodyCheckbox.element as HTMLInputElement).checked).toBe(true) + await bodyCheckbox.setValue(false) + await flushPromises() + expect((findElasticFieldCheckbox('body').element as HTMLInputElement).checked).toBe(false) + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + expect((findElasticFieldCheckbox('body').element as HTMLInputElement).checked).toBe(false) + }) + + it('preserves elasticsearch field uncheck state when the expanded index is not the selected target', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_es', 'Search', 'elasticsearch', '10.0.4.20', 9200) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_es'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'executeStatement').mockImplementation(async (_datasourceId: string, statement: string) => { + if ((statement || '').includes('/_cat/indices?format=json')) { + return { + columns: [], + rows: [ + { index: 'audit-logs', health: 'green', 'store.size': '12mb' }, + { index: 'logs-app', health: 'green', 'store.size': '24mb' }, + ], + rowCount: 2, + elapsedMs: 12, + } as any + } + return { + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any + }) + vi.spyOn(api, 'describeEntity').mockImplementation(async (datasourceId: string, name: string) => { + if (datasourceId === 'ds_es' && name === 'logs-app') { + return { + columns: [ + { name: 'id', dataType: 'long', nullable: '-' }, + { name: 'message', dataType: 'text', nullable: '-' }, + { name: 'source_index', dataType: 'keyword', nullable: '-' }, + ], + indexes: [], + details: [], + } as any + } + if (datasourceId === 'ds_es' && name === 'audit-logs') { + return { + columns: [ + { name: 'action', dataType: 'keyword', nullable: '-' }, + { name: 'actor', dataType: 'keyword', nullable: '-' }, + ], + indexes: [], + details: [], + } as any + } + return { + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + expect(store.selectedEntity).toBe('audit-logs') + + const entityRows = wrapper.findAll('.entity-item') + expect(entityRows).toHaveLength(2) + + await entityRows[1]!.find('.entity-toggle').trigger('click') + await flushPromises() + await flushPromises() + + const findElasticFieldCheckbox = (fieldName: string) => { + const row = wrapper.findAll('.es-index-field-item').find((item) => item.text().includes(fieldName)) + expect(row).toBeTruthy() + return row!.get('input[type="checkbox"]') + } + + const messageCheckbox = findElasticFieldCheckbox('message') + expect((messageCheckbox.element as HTMLInputElement).checked).toBe(true) + await messageCheckbox.setValue(false) + await flushPromises() + expect((findElasticFieldCheckbox('message').element as HTMLInputElement).checked).toBe(false) + expect(store.selectedEntity).toBe('audit-logs') + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_mysql"]').trigger('click') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_es"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_es') + expect(store.selectedEntity).toBe('audit-logs') + + await wrapper.findAll('.entity-item')[1]!.find('.entity-toggle').trigger('click') + await flushPromises() + await flushPromises() + + expect((findElasticFieldCheckbox('message').element as HTMLInputElement).checked).toBe(false) + }) + + it('opens a blank redis query tab without leaking the previous redis session state', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + const scanRedisKeysSpy = vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ + keys: ['sample_key', 'sample_key_2'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_datasourceId: string, name: string) => { + if (name === 'sample_key') { + return { + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'short preview', + truncated: false, + }, + } as any + } + return { + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any + }) + vi.spyOn(api, 'executeStatement').mockImplementation(async (datasourceId: string) => { + if (datasourceId === 'ds_redis') { + return { + columns: ['result'], + rows: [{ result: 'PONG' }], + rowCount: 1, + elapsedMs: 1, + } as any + } + return { + columns: ['answer'], + rows: [{ answer: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + expect(router.currentRoute.value.params.id).toBe('ds_redis') + expect(wrapper.find('[data-testid="redis-proto-shell"]').exists()).toBe(true) + + await wrapper.get('[data-testid="redis-key-search"]').setValue('sample') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + const redisKey = wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes('sample_key')) + expect(redisKey).toBeTruthy() + await redisKey!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="raw"]').trigger('click') + await flushPromises() + + const cliInput = wrapper.get('[data-testid="redis-cli-input"]') + await cliInput.setValue('PING') + await cliInput.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(wrapper.text()).toContain('redis>PING') + expect(wrapper.get('#active-key-title').text()).toContain('sample_key') + + const scanCallsBeforeNewTab = scanRedisKeysSpy.mock.calls.length + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + await flushPromises() + + const tabs = wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs).toHaveLength(3) + expect(scanRedisKeysSpy.mock.calls.length).toBeGreaterThan(scanCallsBeforeNewTab) + expect((wrapper.get('[data-testid="redis-key-search"]').element as HTMLInputElement).value).toBe('') + expect(wrapper.text()).toContain('sample_key') + expect(wrapper.text()).toContain('sample_key_2') + expect(wrapper.get('#active-key-title').text()).toBe('-') + expect(wrapper.find('#stat-ttl').exists()).toBe(false) + expect(wrapper.text()).not.toContain('redis>PING') + expect(wrapper.get('[data-tab="value"]').attributes()).toHaveProperty('disabled') + }) + + it('preserves a cleared redis selection as blank in the active tab snapshot', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource('ds_redis', 'Cache', 'redis', '10.0.3.10', 6379) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_redis'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ + keys: ['sample_key', 'sample_key_2'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} } as any) + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue(null as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'short preview', + truncated: false, + }, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-option"][data-datasource-id="ds_redis"]').trigger('click') + await flushPromises() + await flushPromises() + + const redisKey = wrapper.findAll('#key-list [data-node="row"]').find((button) => button.text().includes('sample_key')) + expect(redisKey).toBeTruthy() + await redisKey!.trigger('click') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushPromises() + + expect(wrapper.get('#active-key-title').text()).toContain('sample_key') + + const viewState = (wrapper.vm as any).$?.setupState?.ctx + expect(viewState).toBeTruthy() + + viewState.store.selectedEntity = '' + viewState.entityDetail.value = null + viewState.resetRedisFullPreview() + await flushPromises() + await flushPromises() + + const activeTab = viewState.statementTabs.value.find((tab: any) => tab.id === viewState.activeStatementTabId.value) + expect(activeTab).toBeTruthy() + expect(String(activeTab.redisState?.selectedKey || '')).toBe('') + }) + + it('shows mongodb host parsed from uri when host/port fields are empty', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_mysql', 'Production', 'mysql', '10.0.0.101', 3306) as any, + makeDatasource( + 'ds_mongo', + 'Analytics', + 'mongodb', + '', + 0, + { options: { uri: 'mongodb://admin:pwd@10.0.2.303:27017/analytics?authSource=admin' } }, + ) as any, + ] + store.status['ds_mysql'] = 'connected' + store.status['ds_mongo'] = 'connected' + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['orders'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + + const mongoOption = wrapper + .findAll('[data-testid="console-datasource-dropdown-option"]') + .find((option) => String(option.attributes('data-datasource-id') || '') === 'ds_mongo') + + expect(mongoOption).toBeTruthy() + expect(mongoOption!.text()).toContain('MongoDB - Analytics | 10.0.2.303:27017') + }) + + it('passes d1 execution mode (remote/dev) when running statements', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_d1', 'Cloud D1', 'd1', '', 0, { + database: 'analytics', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + supportDev: true, + devProjectPath: '/Users/demo/project', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + migrationsDir: 'migrations/cloud-d1', + }, + }) as any, + ] + store.status['ds_d1'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_d1' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['answer'], + rows: [{ answer: 1 }], + rowCount: 1, + elapsedMs: 3, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 1 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const remoteCall = executeSpy.mock.calls.at(-1) + expect(remoteCall?.[5]).toBe('remote') + + await wrapper.get('input[name="d1-execution-mode"][value="dev"]').setValue(true) + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const devCall = executeSpy.mock.calls.at(-1) + expect(devCall?.[5]).toBe('dev') + }) + + it('locks d1 execution mode to remote when datasource does not support dev', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_d1_remote', 'Cloud D1 Remote', 'd1', '', 0, { + database: 'analytics', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + }, + }) as any, + ] + store.status['ds_d1_remote'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_d1_remote' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['answer'], + rows: [{ answer: 1 }], + rowCount: 1, + elapsedMs: 3, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.find('input[name="d1-execution-mode"][value="dev"]').exists()).toBe(false) + expect(wrapper.find('input[name="d1-execution-mode"][value="remote"]').exists()).toBe(true) + + await getStatementEditorInput(wrapper).setValue('SELECT 1 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const remoteCall = executeSpy.mock.calls.at(-1) + expect(remoteCall?.[5]).toBe('remote') + }) + + it('keeps d1 dev mode available for legacy datasource with wrangler config path', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_d1_legacy', 'Cloud D1 Legacy', 'd1', '', 0, { + database: 'analytics', + options: { + accountId: 'acc_legacy', + databaseId: 'db_legacy', + databaseName: 'analytics', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + executionMode: 'dev', + }, + }) as any, + ] + store.status['ds_d1_legacy'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_d1_legacy' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['answer'], + rows: [{ answer: 1 }], + rowCount: 1, + elapsedMs: 3, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.find('input[name="d1-execution-mode"][value="dev"]').exists()).toBe(true) + await wrapper.get('input[name="d1-execution-mode"][value="dev"]').setValue(true) + await getStatementEditorInput(wrapper).setValue('SELECT 1 AS answer;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const devCall = executeSpy.mock.calls.at(-1) + expect(devCall?.[5]).toBe('dev') + }) + + it('shows d1 deploy button in dev mode and triggers deployment', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_d1_dev', 'Cloud D1 Dev', 'd1', '', 0, { + database: 'analytics', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + supportDev: true, + devProjectPath: '/Users/demo/project', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + migrationsDir: 'migrations/cloud-d1-dev', + }, + }) as any, + ] + store.status['ds_d1_dev'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_d1_dev' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + const deploySpy = vi.spyOn(api, 'd1DeployMigrations').mockResolvedValue(undefined as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('input[name="d1-execution-mode"][value="dev"]').setValue(true) + await flushPromises() + + const deployButton = wrapper.find('[data-testid="d1-deploy-button"]') + expect(deployButton.exists()).toBe(true) + await deployButton.trigger('click') + await flushPromises() + + expect(deploySpy).toHaveBeenCalledWith('ds_d1_dev') + }) + + it('uses d1 configured database name in dropdown endpoint and refreshes entities for ddl and mode switch', async () => { + const store = useAppStore() + store.datasources = [ + makeDatasource('ds_d1', 'Cloud D1', 'd1', '', 0, { + database: 'analytics', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics_db_main', + supportDev: true, + devProjectPath: '/Users/demo/project', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + migrationsDir: 'migrations/cloud-d1', + }, + }) as any, + ] + store.status['ds_d1'] = 'connected' + + await router.push({ name: 'console', params: { id: 'ds_d1' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + const listEntitiesPageSpy = vi.spyOn(api, 'listEntitiesPage').mockImplementation( + async (_datasourceId: string, _pattern: string, _database: string, _cursor: string, _limit: number, executionMode = '') => { + if (executionMode === 'dev') { + return { items: ['dev_users'], cursor: '', done: true } as any + } + return { items: ['remote_users'], cursor: '', done: true } as any + }, + ) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'integer', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 1561, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="console-datasource-dropdown-trigger"]').trigger('click') + await flushPromises() + + const d1Option = wrapper + .findAll('[data-testid="console-datasource-dropdown-option"]') + .find((option) => String(option.attributes('data-datasource-id') || '') === 'ds_d1') + + expect(d1Option).toBeTruthy() + expect(d1Option!.text()).toContain('Cloudflare D1 - Cloud D1 | analytics_db_main') + + const remoteCallsBeforeDDL = listEntitiesPageSpy.mock.calls.filter((call) => call[5] === 'remote').length + expect(remoteCallsBeforeDDL).toBeGreaterThan(0) + + await getStatementEditorInput(wrapper).setValue('CREATE TABLE metrics (id INTEGER PRIMARY KEY);') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const remoteCallsAfterDDL = listEntitiesPageSpy.mock.calls.filter((call) => call[5] === 'remote').length + expect(remoteCallsAfterDDL).toBeGreaterThan(remoteCallsBeforeDDL) + expect(wrapper.get('#result-meta').classes()).toContain('result-meta-success') + + await wrapper.get('input[name="d1-execution-mode"][value="dev"]').setValue(true) + await flushPromises() + + const devCalls = listEntitiesPageSpy.mock.calls.filter((call) => call[5] === 'dev').length + expect(devCalls).toBeGreaterThan(0) + + await wrapper.get('input[name="d1-execution-mode"][value="remote"]').setValue(true) + await flushPromises() + + const remoteCallsAfterModeToggle = listEntitiesPageSpy.mock.calls.filter((call) => call[5] === 'remote').length + expect(remoteCallsAfterModeToggle).toBeGreaterThan(remoteCallsAfterDDL) + }) +}) diff --git a/frontend/src/__tests__/console-direction-a-invariants.test.ts b/frontend/src/__tests__/console-direction-a-invariants.test.ts new file mode 100644 index 0000000..f129fcb --- /dev/null +++ b/frontend/src/__tests__/console-direction-a-invariants.test.ts @@ -0,0 +1,176 @@ +import path from 'node:path' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +// Direction A locks in five visual invariants across the shared SQL/Mongo/ES/ +// DynamoDB/D1 parity shell (Redis ships its own copy in RedisConsoleShell.vue). +// This test gates each invariant against a specific CSS rule so a future +// regression (e.g. someone reintroduces a vertical-gradient button or +// a Chrome-box tab) is caught before it ships. +// +// Invariants: +// 1. Underline-indicator tabs: statement tabs + result tabs render the active +// state via a 2px bottom underline (currentColor), not via top-border / +// border-radius / filled background. +// 2. Ghost-indigo toolbar: the SQL editor toolbar secondary buttons start +// transparent and shift to a primary-tinted hover. No linear-gradient bg. +// 3. Surface-flat results header: result-header-sql-editor uses +// var(--sql-editor-surface), not a surface-soft tint. +// 4. Solid-primary run buttons: elastic-run-btn + chroma-dsl-run-btn use a +// solid var(--primary) fill, not a vertical gradient. +// 5. Ghost-indigo per-DB controls: dynamo-limit-trigger and entity-panel- +// refresh-button are transparent-by-default and primary-tinted on hover. + +const css = readCssWithImports(path.resolve(__dirname, '..', 'style.css')) + +const segmentedTabsSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/components/primitives/ConsoleSegmentedTabs.vue'), + 'utf8', +) +const paneHeaderSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/components/primitives/ConsolePaneHeader.vue'), + 'utf8', +) +const inlineMetaSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/components/primitives/ConsoleInlineMeta.vue'), + 'utf8', +) + +const grabRule = (selectorRegex: RegExp): string => css.match(selectorRegex)?.[0] ?? '' + +describe('console Direction A invariants — shared parity shell', () => { + it('statement tabs use a 2px primary underline as the active indicator', () => { + const activeAfter = grabRule(/\.statement-tab--sql-editor\.active::after\s*\{[\s\S]*?\}/) + const activeRule = grabRule(/\.statement-tab--sql-editor\.active\s*\{[\s\S]*?\}/) + + expect(activeAfter).toMatch(/content:\s*['"]['"]/i) + expect(activeAfter).toMatch(/height:\s*2px/i) + expect(activeAfter).toMatch(/bottom:\s*-1px/i) + expect(activeAfter).toMatch(/background:\s*var\(--primary\)/i) + // Active tab text + underline both render in primary. + expect(activeRule).toMatch(/color:\s*var\(--primary\)/i) + }) + + it('result tabs use the same underline-indicator language (no Chrome-style box)', () => { + const tab = grabRule(/\.console-results-content--sql-editor\s+\.result-tab\s*\{[\s\S]*?\}/) + const active = grabRule(/\.console-results-content--sql-editor\s+\.result-tab\.active\s*\{[\s\S]*?\}/) + const activeAfter = grabRule(/\.console-results-content--sql-editor\s+\.result-tab\.active::after\s*\{[\s\S]*?\}/) + const tabs = grabRule(/\.console-results-content--sql-editor\s+\.result-tabs\s*\{[\s\S]*?\}/) + + expect(tab).toMatch(/background:\s*transparent/i) + expect(tab).toMatch(/border:\s*none/i) + expect(active).toMatch(/color:\s*var\(--primary\)/i) + expect(activeAfter).toMatch(/height:\s*2px/i) + expect(activeAfter).toMatch(/background:\s*currentColor/i) + expect(tabs).toMatch(/background:\s*transparent/i) + }) + + it('SQL toolbar secondary buttons are ghost-indigo (transparent base, primary-tinted hover)', () => { + const base = grabRule(/\.editor-toolbar-sql-editor\s+\.toolbar-left\s+button\s*\{[\s\S]*?\}/) + const hover = grabRule(/\.editor-toolbar-sql-editor\s+\.toolbar-left\s+button:hover:not\(:disabled\)\s*\{[\s\S]*?\}/) + + expect(base).toMatch(/background:\s*transparent/i) + expect(base).toMatch(/border:\s*1px\s+solid\s+transparent/i) + expect(base).not.toMatch(/linear-gradient/i) + expect(hover).toMatch(/color:\s*var\(--primary\)/i) + expect(hover).toMatch(/var\(--primary\)\s*8%/i) + }) + + it('execute-btn stays solid primary (single accent per pane)', () => { + const execute = grabRule(/\.editor-toolbar-sql-editor\s+\.toolbar-left\s+\.execute-btn\s*\{[\s\S]*?\}/) + + expect(execute).toMatch(/background:\s*var\(--primary/i) + expect(execute).toMatch(/color:\s*var\(--primary-foreground/i) + expect(execute).not.toMatch(/linear-gradient/i) + }) + + it('results header sits on the editor surface (no surface-soft tint)', () => { + const header = grabRule(/\.result-header-sql-editor\s*\{[\s\S]*?\}/) + const headerH2 = grabRule(/\.result-header-sql-editor\s+h2\s*\{[\s\S]*?\}/) + + expect(header).toMatch(/background:\s*var\(--sql-editor-surface\)/i) + // 13px / 600 is the Direction A panel-title size. + expect(headerH2).toMatch(/font-size:\s*13px/i) + expect(headerH2).toMatch(/font-weight:\s*600/i) + }) + + it('per-DB run buttons (elastic, chroma) use a solid primary fill, not a vertical gradient', () => { + const elastic = grabRule(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-run-btn\s*\{[\s\S]*?\}/) + const chroma = grabRule(/\.console-panel--statement\.sql-editor-parity\s+\.chroma-dsl-run-btn\s*\{[\s\S]*?\}/) + + expect(elastic).toMatch(/background:\s*var\(--primary\)/i) + expect(elastic).not.toMatch(/linear-gradient/i) + expect(chroma).toMatch(/background:\s*var\(--primary\)/i) + expect(chroma).not.toMatch(/linear-gradient/i) + }) + + it('elastic DSL drawer drops the radial+linear gradient backdrop', () => { + const drawer = grabRule(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-drawer\s*\{[\s\S]*?\}/) + + expect(drawer).toMatch(/background:\s*var\(--sql-editor-surface\)/i) + expect(drawer).not.toMatch(/linear-gradient/i) + expect(drawer).not.toMatch(/radial-gradient/i) + }) + + it('dynamo-limit-trigger is ghost-indigo with a 32px hit target', () => { + const base = grabRule(/\.dynamo-limit-controls\s+\.dynamo-limit-trigger,[\s\S]*?\.editor-toolbar-sql-editor\s+\.toolbar-left\s+\.dynamo-limit-trigger\s*\{[\s\S]*?\}/) + const hover = grabRule(/\.dynamo-limit-controls\s+\.dynamo-limit-trigger:hover,[\s\S]*?\.editor-toolbar-sql-editor\s+\.toolbar-left\s+\.dynamo-limit-trigger:hover\s*\{[\s\S]*?\}/) + + expect(base).toMatch(/background:\s*transparent/i) + expect(base).toMatch(/min-height:\s*32px/i) + expect(base).not.toMatch(/linear-gradient/i) + expect(hover).toMatch(/color:\s*var\(--primary/i) + expect(hover).toMatch(/var\(--primary[^)]*\)\s*8%/i) + }) + + it('dynamo hint card uses a flat tint instead of a gradient + shadow', () => { + const card = grabRule(/\.dynamo-hint-card\s*\{[\s\S]*?\}/) + const primary = grabRule(/\.dynamo-hint-card-button--primary\s*\{[\s\S]*?\}/) + + expect(card).not.toMatch(/linear-gradient/i) + expect(card).toMatch(/box-shadow:\s*none/i) + expect(primary).toMatch(/background:\s*var\(--hint-accent\)/i) + expect(primary).not.toMatch(/linear-gradient/i) + }) + + it('entity-panel refresh button is ghost-indigo (transparent base, primary-tinted hover)', () => { + const base = grabRule(/\.entity-panel-refresh-button\s*\{[\s\S]*?\}/) + const hover = grabRule(/\.entity-panel-refresh-button:hover:not\(:disabled\)\s*\{[\s\S]*?\}/) + + expect(base).toMatch(/background:\s*transparent/i) + expect(base).toMatch(/border:\s*1px\s+solid\s+transparent/i) + expect(base).not.toMatch(/linear-gradient/i) + expect(hover).toMatch(/color:\s*var\(--primary\)/i) + expect(hover).toMatch(/var\(--primary\)\s*8%/i) + }) + + it('entities panel-head title shrinks to 13px/600 (Direction A panel-title size)', () => { + const title = grabRule(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head\s+h4\s*\{[\s\S]*?\}/) + + expect(title).toMatch(/font-size:\s*13px/i) + expect(title).toMatch(/font-weight:\s*600/i) + }) + + it('elastic-stitch does NOT push entities title back to 18px (parity with other DBs)', () => { + // Direction A locks 13px/600 across all consoles. Elastic-stitch used to + // override `.panel-head h4 { font-size: 18px }` which broke parity. The + // override is intentionally removed so the shared 13px/600 wins. + expect(css).not.toMatch(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.panel-head\s+h4\s*\{[\s\S]*?font-size:\s*18px/i) + }) + + it('shared primitives exist and expose the Direction A surface contract', () => { + expect(segmentedTabsSource).toContain('role="tablist"') + expect(segmentedTabsSource).toContain('role="tab"') + expect(segmentedTabsSource).toContain('console-seg-tab--active') + expect(segmentedTabsSource).toMatch(/background:\s*currentColor/) + + expect(paneHeaderSource).toContain('min-h-[44px]') + expect(paneHeaderSource).toContain('shrink-0') + expect(paneHeaderSource).toContain('slot name="actions"') + + expect(inlineMetaSource).toContain('console-inline-meta') + expect(inlineMetaSource).toContain('text-[12.5px]') + }) +}) diff --git a/frontend/src/__tests__/console-dynamodb-auto-pagination.test.ts b/frontend/src/__tests__/console-dynamodb-auto-pagination.test.ts new file mode 100644 index 0000000..b308270 --- /dev/null +++ b/frontend/src/__tests__/console-dynamodb-auto-pagination.test.ts @@ -0,0 +1,185 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_ddb' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const getStatementEditorInput = (wrapper: ReturnType) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} + +const getExecuteButton = (wrapper: ReturnType) => { + const parityButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + if (parityButton.exists()) return parityButton + return wrapper.findAll('button').find((btn) => btn.text() === 'Execute') +} + +describe('ConsoleView DynamoDB auto pagination', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + setAppLocale('zh') + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + resetAppI18nForTest() + }) + + it('paginates dynamodb execute results using tokens', async () => { + const rows = Array.from({ length: 101 }, (_, idx) => ({ pk: `USER#${idx + 1}` })) + const nextRows = Array.from({ length: 30 }, (_, idx) => ({ pk: `USER#${idx + 102}` })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + rows, + rowCount: rows.length, + nextToken: 'next-token', + elapsedMs: 12, + detail: { + effectivePageSize: 25, + maxPages: 3, + maxEvaluatedItems: 300, + pagesFetched: 1, + stopReason: 'page_limit', + }, + }) + .mockResolvedValueOnce({ + rows: nextRows, + rowCount: nextRows.length, + nextToken: 'next-token-2', + elapsedMs: 12, + detail: { + effectivePageSize: 25, + maxPages: 3, + maxEvaluatedItems: 300, + pagesFetched: 1, + stopReason: 'evaluated_item_limit', + }, + }) + .mockResolvedValueOnce({ + rows: [{ pk: 'USER#132' }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { region: 'us-east-1' }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const trigger = wrapper.find('.dynamo-limit-trigger') + expect(trigger.exists()).toBe(true) + await trigger.trigger('click') + await flushPromises() + + const limitInputs = Array.from( + document.body.querySelectorAll('.dynamo-limit-popover .dynamo-limit-field input'), + ) + expect(limitInputs).toHaveLength(3) + const setLimit = async (input: HTMLInputElement, value: string) => { + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + await flushPromises() + } + await setLimit(limitInputs[0], '25') + await setLimit(limitInputs[1], '40') + await setLimit(limitInputs[2], '3') + + await getStatementEditorInput(wrapper).setValue('SELECT * FROM \"orders\" LIMIT 100') + const executeButton = getExecuteButton(wrapper) + expect(executeButton).toBeTruthy() + + await executeButton?.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + expect(executeSpy.mock.calls[0]?.[0]).toBe('ds_ddb') + expect(executeSpy.mock.calls[0]?.[1]).toBe('SELECT * FROM \"orders\" LIMIT 100') + expect(executeSpy.mock.calls[0]?.[3]).toBe('') + expect(executeSpy.mock.calls[0]?.[4]).toBe(25) + expect(executeSpy.mock.calls[0]?.[7]).toEqual({ + maxReturnedRows: 40, + maxPages: 3, + maxEvaluatedItems: 75, + }) + expect(wrapper.text()).toContain('单页 25') + expect(wrapper.text()).toContain('页数 3') + expect(wrapper.text()).not.toContain('评估 300') + expect(wrapper.text()).toContain('已读取 1 页') + expect(wrapper.text()).toContain('已按页数限制停止。') + expect(wrapper.text()).not.toContain('pageSize 25') + expect(wrapper.text()).not.toContain('maxPages 3') + expect(wrapper.text()).not.toContain('maxEval 300') + + const resultEl = wrapper.find('#result').element as HTMLElement + Object.defineProperty(resultEl, 'scrollTop', { value: 900, writable: true, configurable: true }) + Object.defineProperty(resultEl, 'clientHeight', { value: 200, configurable: true }) + Object.defineProperty(resultEl, 'scrollHeight', { value: 1000, configurable: true }) + await wrapper.find('#result').trigger('scroll') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[0]).toBe('ds_ddb') + expect(executeSpy.mock.calls[1]?.[1]).toBe('SELECT * FROM \"orders\" LIMIT 100') + expect(executeSpy.mock.calls[1]?.[3]).toBe('next-token') + expect(executeSpy.mock.calls[1]?.[4]).toBe(25) + expect(executeSpy.mock.calls[1]?.[7]).toEqual({ + maxReturnedRows: 40, + maxPages: 3, + maxEvaluatedItems: 75, + }) + expect(wrapper.text()).toContain('USER#131') + expect(wrapper.text()).toContain('已按评估 item 限制停止。') + expect(wrapper.text()).not.toContain('stop evaluated_item_limit') + + await wrapper.find('#result').trigger('scroll') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(3) + expect(executeSpy.mock.calls[2]?.[0]).toBe('ds_ddb') + expect(executeSpy.mock.calls[2]?.[1]).toBe('SELECT * FROM \"orders\" LIMIT 100') + expect(executeSpy.mock.calls[2]?.[3]).toBe('next-token-2') + expect(executeSpy.mock.calls[2]?.[4]).toBe(25) + expect(executeSpy.mock.calls[2]?.[7]).toEqual({ + maxReturnedRows: 40, + maxPages: 3, + maxEvaluatedItems: 75, + }) + }) +}) diff --git a/frontend/src/__tests__/console-dynamodb-helpers.test.ts b/frontend/src/__tests__/console-dynamodb-helpers.test.ts new file mode 100644 index 0000000..faf3eec --- /dev/null +++ b/frontend/src/__tests__/console-dynamodb-helpers.test.ts @@ -0,0 +1,60 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_ddb' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView DynamoDB helpers', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [{ label: 'Partition Key', value: 'pk' }], + } as any) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('seeds a DynamoDB parity template with selected table details', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + options: { region: 'us-east-1' }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(wrapper.find('.editor-toolbar-sql-editor .toolbar-status').text()).toMatch(/dynamo/i) + const statement = (getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value + expect(statement).toContain('SELECT * FROM "users"') + expect(statement).toContain("WHERE \"pk\" = 'PK#...'") + }) +}) diff --git a/frontend/src/__tests__/console-dynamodb-hint-card.test.ts b/frontend/src/__tests__/console-dynamodb-hint-card.test.ts new file mode 100644 index 0000000..8c2498b --- /dev/null +++ b/frontend/src/__tests__/console-dynamodb-hint-card.test.ts @@ -0,0 +1,122 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_ddb' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const getStatementEditorInput = (wrapper: ReturnType) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} + +const getExecuteButton = (wrapper: ReturnType) => { + const parityButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + if (parityButton.exists()) return parityButton + return wrapper.findAll('button').find((btn) => btn.text() === 'Execute') +} + +describe('DynamoDB statement-repair hint card', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + setAppLocale('en') + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + resetAppI18nForTest() + }) + + it('renders the redesigned hint card and "Apply & run" replaces editor text and re-runs', async () => { + const repairedStatement = 'SELECT * FROM "orders" WHERE pk = \'PK#1\'' + const executeSpy = vi + .spyOn(api, 'executeStatement') + // First call: returns a result with statementRepair detail. + .mockResolvedValueOnce({ + rows: [], + rowCount: 0, + elapsedMs: 5, + detail: { + effectivePageSize: 100, + statementRepair: { + kind: 'partiql-quoting', + originalStatement: 'SELECT * FROM orders WHERE pk = "PK#1"', + repairedStatement, + reason: 'Single-quoted PartiQL string literal applied.', + }, + }, + }) + // Second call (triggered by Apply & run): plain success. + .mockResolvedValueOnce({ + rows: [{ pk: 'PK#1' }], + rowCount: 1, + elapsedMs: 4, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { region: 'us-east-1' }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { plugins: [pinia] }, + }) + + await flushPromises() + + const editorInput = getStatementEditorInput(wrapper) + await editorInput.setValue('SELECT * FROM orders WHERE pk = "PK#1"') + const executeButton = getExecuteButton(wrapper) + expect(executeButton).toBeTruthy() + await executeButton?.trigger('click') + await flushPromises() + + const repairCard = wrapper.find('.dynamo-hint-card--repair') + expect(repairCard.exists()).toBe(true) + expect(repairCard.text()).toContain('Auto-repaired statement') + expect(repairCard.text()).toContain(repairedStatement) + + const primary = repairCard.find('.dynamo-hint-card-button--primary') + expect(primary.exists()).toBe(true) + expect(primary.text()).toContain('Apply & run') + + const secondary = repairCard + .findAll('.dynamo-hint-card-button') + .find((btn) => !btn.classes().includes('dynamo-hint-card-button--primary')) + expect(secondary?.text()).toContain('Replace only') + + expect(executeSpy).toHaveBeenCalledTimes(1) + await primary.trigger('click') + await flushPromises() + + const editorAfter = getStatementEditorInput(wrapper) + expect((editorAfter.element as HTMLTextAreaElement).value).toBe(repairedStatement) + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[1]).toBe(repairedStatement) + }) +}) diff --git a/frontend/src/__tests__/console-elastic-dsl-workspace.test.ts b/frontend/src/__tests__/console-elastic-dsl-workspace.test.ts new file mode 100644 index 0000000..0e98b31 --- /dev/null +++ b/frontend/src/__tests__/console-elastic-dsl-workspace.test.ts @@ -0,0 +1,1904 @@ +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import ConsoleElasticDslWorkspace from '@/views/console/components/elastic-dsl/ConsoleElasticDslWorkspace.vue' + +const parseStatementBody = (statement: string) => { + const normalized = String(statement || '').replace(/\r\n/g, '\n') + const body = normalized.split('\n').slice(1).join('\n') + return JSON.parse(body) +} + +const rect = (top: number, left: number, width: number, height: number) => ({ + x: left, + y: top, + top, + left, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), +}) as DOMRect + +describe('ConsoleElasticDslWorkspace', () => { + it('removes rendered filter chips by original bool.filter index', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { exists: { field: 'status' } }, + { term: { level: 'error' } }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(2) + const levelChip = chips.find((chip) => chip.text().includes('level')) + expect(levelChip).toBeTruthy() + await levelChip!.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([{ exists: { field: 'status' } }]) + }) + + it('uses current index fields as dropdown options when adding filter', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['action_time', 'entity_id'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + expect(wrapper.get('[data-testid="elastic-dsl-field-option-action_time"]').text()).toContain('action_time') + expect(wrapper.get('[data-testid="elastic-dsl-field-option-entity_id"]').text()).toContain('entity_id') + + await wrapper.get('[data-testid="elastic-dsl-field-option-entity_id"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('=') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('0001') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + expect(latestStatement.split('\n')[0]).toBe('POST /logs/_search') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + term: { + entity_id: '0001', + }, + }, + ]) + }) + + it('filters available fields through the searchable field popover', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'source_index', type: 'keyword' }, + { name: 'message', type: 'text' }, + { name: 'value', type: 'long' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-field-search"]').setValue('mess') + + expect(wrapper.find('[data-testid="elastic-dsl-field-option-source_index"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="elastic-dsl-field-option-message"]').text()).toContain('message') + + await wrapper.get('[data-testid="elastic-dsl-field-option-message"]').trigger('click') + expect(wrapper.get('[data-testid="elastic-dsl-filter-field"]').text()).toContain('message') + }) + + it('offers a custom field option when the search keyword does not match visible fields', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'message', type: 'text' }, + { name: 'user.id', type: 'keyword' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-field-search"]').setValue('field_version.build') + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-custom"]').text()).toContain('field_version.build') + + await wrapper.get('[data-testid="elastic-dsl-field-option-custom"]').trigger('click') + expect(wrapper.get('[data-testid="elastic-dsl-filter-field"]').text()).toContain('field_version.build') + }) + + it('does not offer a custom field option when the search keyword still matches visible fields', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'message', type: 'text' }, + { name: 'config.theme', type: 'keyword' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-field-search"]').setValue('config') + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + expect(wrapper.find('[data-testid="elastic-dsl-field-option-custom"]').exists()).toBe(false) + + await wrapper.get('[data-testid="elastic-dsl-field-search"]').trigger('keydown.enter') + + expect(wrapper.get('[data-testid="elastic-dsl-filter-field"]').text()).toContain('message') + expect(wrapper.find('.elastic-dsl-field-popover').exists()).toBe(true) + }) + + it('closes the field popover when clicking outside the elastic dsl filter builder', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + attachTo: document.body, + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['action_time', 'entity_id'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + + expect(wrapper.find('.elastic-dsl-field-popover').exists()).toBe(true) + + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + + expect(wrapper.find('.elastic-dsl-field-popover').exists()).toBe(false) + wrapper.unmount() + }) + + it('anchors the field popover directly below the trigger while keeping in-menu interactions open', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + attachTo: document.body, + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'source_index', type: 'keyword' }, + { name: 'message', type: 'text' }, + { name: 'value', type: 'long' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + try { + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.vm.$nextTick() + + const triggerEl = wrapper.get('[data-testid="elastic-dsl-filter-field"]').element as HTMLElement + const popoverEl = wrapper.get('.elastic-dsl-field-popover').element as HTMLElement + + vi.spyOn(triggerEl, 'getBoundingClientRect').mockImplementation(() => rect(160, 120, 236, 32)) + vi.spyOn(popoverEl, 'getBoundingClientRect').mockImplementation(() => rect(0, 0, 236, 232)) + + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + + expect(wrapper.get('.elastic-dsl-field-popover').attributes('data-placement')).toBe('below') + expect(popoverEl.style.position).toBe('fixed') + expect(popoverEl.style.top).toBe('198px') + expect(popoverEl.style.left).toBe('120px') + expect(popoverEl.style.width).toBe('236px') + + const searchInput = wrapper.get('[data-testid="elastic-dsl-field-search"]').element as HTMLInputElement + searchInput.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + await nextTick() + + expect(wrapper.find('.elastic-dsl-field-popover').exists()).toBe(true) + } finally { + wrapper.unmount() + } + }) + + it('repositions the field popover above the trigger when the viewport is too short', async () => { + const previousInnerHeight = window.innerHeight + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 620, + }) + const wrapper = mount(ConsoleElasticDslWorkspace, { + attachTo: document.body, + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'source_index', type: 'keyword' }, + { name: 'message', type: 'text' }, + { name: 'value', type: 'long' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + try { + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.vm.$nextTick() + + const triggerEl = wrapper.get('[data-testid="elastic-dsl-filter-field"]').element as HTMLElement + const popoverEl = wrapper.get('.elastic-dsl-field-popover').element as HTMLElement + + vi.spyOn(triggerEl, 'getBoundingClientRect').mockImplementation(() => rect(551, 336, 236, 32)) + vi.spyOn(popoverEl, 'getBoundingClientRect').mockImplementation(() => rect(0, 0, 236, 232)) + + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + + expect(wrapper.get('.elastic-dsl-field-popover').attributes('data-placement')).toBe('above') + expect(popoverEl.style.position).toBe('fixed') + expect(popoverEl.style.bottom).toBe('75px') + } finally { + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: previousInnerHeight, + }) + wrapper.unmount() + } + }) + + it('keeps the field popover below inside clipping containers by positioning it to the viewport', async () => { + const clippingHost = document.createElement('div') + clippingHost.style.overflow = 'hidden' + document.body.appendChild(clippingHost) + + const wrapper = mount(ConsoleElasticDslWorkspace, { + attachTo: clippingHost, + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'source_index', type: 'keyword' }, + { name: 'message', type: 'text' }, + { name: 'value', type: 'long' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + try { + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.vm.$nextTick() + + const triggerEl = wrapper.get('[data-testid="elastic-dsl-filter-field"]').element as HTMLElement + const popoverEl = wrapper.get('.elastic-dsl-field-popover').element as HTMLElement + + vi.spyOn(triggerEl, 'getBoundingClientRect').mockImplementation(() => rect(284, 336, 236, 32)) + vi.spyOn(popoverEl, 'getBoundingClientRect').mockImplementation(() => rect(0, 0, 236, 129)) + vi.spyOn(clippingHost, 'getBoundingClientRect').mockImplementation(() => rect(126, 0, 900, 233)) + + window.dispatchEvent(new Event('resize')) + await wrapper.vm.$nextTick() + + expect(wrapper.get('.elastic-dsl-field-popover').attributes('data-placement')).toBe('below') + expect(popoverEl.style.position).toBe('fixed') + expect(popoverEl.style.top).toBe('322px') + } finally { + wrapper.unmount() + clippingHost.remove() + } + }) + + it('keeps the operator control as a native select instead of a custom popover trigger', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + const operatorSelect = wrapper.get('.elastic-dsl-filter-operator-select') + + expect(wrapper.find('.elastic-dsl-filter-operator-trigger').exists()).toBe(false) + expect(wrapper.find('.elastic-dsl-operator-popover').exists()).toBe(false) + + await operatorSelect.setValue('contains') + + expect((operatorSelect.element as HTMLSelectElement).value).toBe('contains') + expect(wrapper.find('.elastic-dsl-filter-value-composer').exists()).toBe(true) + }) + + it('lets two-value should groups grow the live dsl code panel past the old 540px ceiling', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [{ match_all: {} }], + filter: [ + { + bool: { + should: [ + { match: { message: 'seed' } }, + { match: { message: 'doc' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + const shell = wrapper.get('.elastic-dsl-editor-shell').element as HTMLDivElement + expect(Number.parseInt(shell.style.getPropertyValue('--elastic-dsl-editor-height'), 10)).toBeGreaterThan(540) + }) + + it('caps very long live dsl json inside the drawer so the editor can scroll vertically', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [{ match_all: {} }], + filter: Array.from({ length: 48 }, (_, idx) => ({ + term: { + [`message.keyword.${idx}`]: `seed-${idx}`, + }, + })), + }, + }, + size: 50, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const shell = wrapper.get('.elastic-dsl-editor-shell').element as HTMLDivElement + expect(shell.style.getPropertyValue('--elastic-dsl-editor-height')).toBe('720px') + }) + + it('caps medium live dsl json to the visible statement-panel height instead of letting the shell overflow its parent', async () => { + const statementPanelHost = document.createElement('div') + statementPanelHost.className = 'console-panel--statement sql-editor-parity' + document.body.appendChild(statementPanelHost) + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + size: 50, + query: { + bool: { + must: [{ match_all: {} }], + filter: [ + { + bool: { + should: [ + { match: { message: 'doc' } }, + { match: { message: 'seed' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + attachTo: statementPanelHost, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const shell = wrapper.get('.elastic-dsl-editor-shell').element as HTMLDivElement + const drawer = wrapper.get('.elastic-dsl-drawer').element as HTMLDivElement + + vi.spyOn(statementPanelHost, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 915, + bottom: 752, + width: 915, + height: 752, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(shell, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 389, + top: 389, + left: 0, + right: 860, + bottom: 977, + width: 860, + height: 588, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 331, + top: 331, + left: 0, + right: 884, + bottom: 999, + width: 884, + height: 668, + toJSON: () => ({}), + } as DOMRect) + + window.dispatchEvent(new Event('resize')) + await nextTick() + + expect(Number.parseInt(shell.style.getPropertyValue('--elastic-dsl-editor-height'), 10)).toBeLessThan(588) + } finally { + statementPanelHost.remove() + } + }) + + it('requests a taller editor/results split when the constrained shell would otherwise collapse below a usable height', async () => { + const resultsShellHost = document.createElement('div') + resultsShellHost.className = 'console-editor-results-shell sql-editor-parity' + resultsShellHost.style.setProperty('--console-editor-height', '360px') + + const statementPanelHost = document.createElement('div') + statementPanelHost.className = 'console-panel--statement sql-editor-parity' + resultsShellHost.appendChild(statementPanelHost) + document.body.appendChild(resultsShellHost) + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + size: 50, + query: { + bool: { + must: [{ match_all: {} }], + filter: [ + { + bool: { + should: [ + { match: { message: 'doc' } }, + { match: { message: 'seed' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + attachTo: statementPanelHost, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const shell = wrapper.get('.elastic-dsl-editor-shell').element as HTMLDivElement + const drawer = wrapper.get('.elastic-dsl-drawer').element as HTMLDivElement + + vi.spyOn(statementPanelHost, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 740, + bottom: 752, + width: 740, + height: 752, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(shell, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 700, + top: 700, + left: 0, + right: 660, + bottom: 730, + width: 660, + height: 30, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 377, + top: 377, + left: 0, + right: 684, + bottom: 752, + width: 684, + height: 375, + toJSON: () => ({}), + } as DOMRect) + + window.dispatchEvent(new Event('resize')) + await nextTick() + + expect(Number.parseInt(resultsShellHost.style.getPropertyValue('--elastic-live-dsl-min-editor-height'), 10)).toBeGreaterThan(360) + } finally { + resultsShellHost.remove() + } + }) + + it('does not force a taller live dsl split on narrow desktop widths where results need their own visible lane', async () => { + const resultsShellHost = document.createElement('div') + resultsShellHost.className = 'console-editor-results-shell sql-editor-parity' + resultsShellHost.style.setProperty('--console-editor-height', '280px') + + const statementPanelHost = document.createElement('div') + statementPanelHost.className = 'console-panel--statement sql-editor-parity' + resultsShellHost.appendChild(statementPanelHost) + document.body.appendChild(resultsShellHost) + + const originalInnerWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: 790, + }) + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + size: 50, + query: { + bool: { + must: [{ term: { status: 'active' } }], + should: [{ match: { room_name: 'dylan' } }], + }, + }, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + attachTo: statementPanelHost, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const shell = wrapper.get('.elastic-dsl-editor-shell').element as HTMLDivElement + const drawer = wrapper.get('.elastic-dsl-drawer').element as HTMLDivElement + const workspace = wrapper.get('.elastic-dsl-workspace').element as HTMLDivElement + + vi.spyOn(resultsShellHost, 'getBoundingClientRect').mockReturnValue(rect(214, 0, 790, 576)) + vi.spyOn(statementPanelHost, 'getBoundingClientRect').mockReturnValue(rect(214, 253, 303, 576)) + vi.spyOn(workspace, 'getBoundingClientRect').mockReturnValue(rect(256, 253, 303, 534)) + vi.spyOn(drawer, 'getBoundingClientRect').mockReturnValue(rect(559, 253, 303, 231)) + vi.spyOn(shell, 'getBoundingClientRect').mockReturnValue(rect(658, 253, 303, 109)) + + window.dispatchEvent(new Event('resize')) + await nextTick() + + expect(resultsShellHost.style.getPropertyValue('--elastic-live-dsl-min-editor-height')).toBe('') + } finally { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: originalInnerWidth, + }) + resultsShellHost.remove() + } + }) + + it('requests a taller editor/results split when the add-filter editor would be clipped at narrow widths', async () => { + const resultsShellHost = document.createElement('div') + resultsShellHost.className = 'console-editor-results-shell sql-editor-parity' + resultsShellHost.style.setProperty('--console-editor-height', '320px') + + const statementPanelHost = document.createElement('div') + statementPanelHost.className = 'console-statement-panel--sql-editor' + resultsShellHost.appendChild(statementPanelHost) + document.body.appendChild(resultsShellHost) + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'POST /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'message', type: 'text' }, + { name: 'user.id', type: 'keyword' }, + ], + canExecute: true, + canBeautify: true, + }, + attachTo: statementPanelHost, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + + const workspace = wrapper.get('.elastic-dsl-workspace').element as HTMLElement + + vi.spyOn(statementPanelHost, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 216, + top: 216, + left: 0, + right: 523, + bottom: 568, + width: 523, + height: 352, + toJSON: () => ({}), + } as DOMRect) + vi.spyOn(workspace, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 258, + top: 258, + left: 0, + right: 523, + bottom: 617, + width: 523, + height: 359, + toJSON: () => ({}), + } as DOMRect) + + window.dispatchEvent(new Event('resize')) + await nextTick() + + expect(Number.parseInt(resultsShellHost.style.getPropertyValue('--elastic-live-dsl-min-editor-height'), 10)).toBeGreaterThan(352) + } finally { + resultsShellHost.remove() + } + }) + + it('exposes a visible right-side scroll indicator when long dsl content overflows vertically', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [{ match_all: {} }], + filter: Array.from({ length: 48 }, (_, idx) => ({ + term: { + [`message.keyword.${idx}`]: `seed-${idx}`, + }, + })), + }, + }, + size: 50, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + attachTo: document.body, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const editor = wrapper.get('.elastic-dsl-editor').element as HTMLTextAreaElement + const pane = wrapper.get('.elastic-dsl-editor-pane').element as HTMLDivElement + + Object.defineProperty(editor, 'clientHeight', { configurable: true, value: 720 }) + Object.defineProperty(editor, 'scrollHeight', { configurable: true, value: 5626 }) + Object.defineProperty(pane, 'clientHeight', { configurable: true, value: 720 }) + + editor.scrollTop = 240 + editor.dispatchEvent(new Event('scroll')) + + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-opacity')).toBe('1') + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-thumb-height')).not.toBe('') + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-thumb-offset')).not.toBe('') + }) + + it('recomputes the right-side scroll indicator when late layout sizing arrives without manual scrolling', async () => { + const observedElements: Element[] = [] + let resizeCallback: ResizeObserverCallback | null = null + + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + + observe(target: Element) { + observedElements.push(target) + } + + disconnect() {} + + unobserve() {} + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock as unknown as typeof ResizeObserver) + vi.stubGlobal('requestAnimationFrame', ((callback: FrameRequestCallback) => { + callback(performance.now()) + return 1 + }) as typeof requestAnimationFrame) + vi.stubGlobal('cancelAnimationFrame', (() => {}) as typeof cancelAnimationFrame) + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [{ match_all: {} }], + filter: Array.from({ length: 48 }, (_, idx) => ({ + term: { + [`message.keyword.${idx}`]: `seed-${idx}`, + }, + })), + }, + }, + size: 50, + }, + null, + 2, + ), + ].join('\n'), + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + attachTo: document.body, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const editor = wrapper.get('.elastic-dsl-editor').element as HTMLTextAreaElement + const pane = wrapper.get('.elastic-dsl-editor-pane').element as HTMLDivElement + + expect(resizeCallback).toBeTypeOf('function') + + Object.defineProperty(editor, 'clientHeight', { configurable: true, value: 720 }) + Object.defineProperty(editor, 'scrollHeight', { configurable: true, value: 5626 }) + Object.defineProperty(pane, 'clientHeight', { configurable: true, value: 720 }) + + resizeCallback?.([], {} as ResizeObserver) + await nextTick() + + expect(observedElements).toContain(editor) + expect(observedElements).toContain(pane) + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-opacity')).toBe('1') + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-thumb-height')).not.toBe('') + expect(pane.style.getPropertyValue('--elastic-dsl-scrollbar-thumb-offset')).not.toBe('') + } finally { + vi.unstubAllGlobals() + } + }) + + it('keeps case-distinct elastic field names as separate picker options', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: [ + { name: 'UserID', type: 'keyword' }, + { name: 'userid', type: 'keyword' }, + ], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-UserID"]').text()).toContain('UserID') + expect(wrapper.get('[data-testid="elastic-dsl-field-option-userid"]').text()).toContain('userid') + }) + + it('allows manual filter field input when mappings are unavailable', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /_search\n{}', + selectedTargetPath: '', + availableFields: [], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').setValue('user.id') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('=') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('1') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + term: { + 'user.id': '1', + }, + }, + ]) + }) + + it('wraps top-level match_all into bool.must when adding filters', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'POST /logs/_search\n{\n "query": {\n "match_all": {}\n }\n}', + selectedTargetPath: 'logs', + availableFields: ['user.id'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('=') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('1') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + + expect(updatedBody.query.match_all).toBeUndefined() + expect(updatedBody.query.bool.must).toEqual([{ match_all: {} }]) + expect(updatedBody.query.bool.filter).toEqual([ + { + term: { + 'user.id': '1', + }, + }, + ]) + }) + + it('supports terms query for in operator', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('in') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('active, archived , pending') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + terms: { + status: ['active', 'archived', 'pending'], + }, + }, + ]) + }) + + it('does not emit empty terms clauses for in operator with invalid list input', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('in') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue(', ,') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + expect(wrapper.emitted('update:statement')).toBeFalsy() + }) + + it('disables apply filter and preserves invalid live dsl without emitting updates', async () => { + const invalidStatement = 'GET /logs/_search\n{\n "query": ' + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: invalidStatement, + selectedTargetPath: 'logs', + availableFields: [], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').setValue('status') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('active') + + const applyButton = wrapper.get('[data-testid="elastic-dsl-apply-filter"]') + expect(applyButton.attributes('disabled')).toBeDefined() + + await applyButton.trigger('click') + + expect(wrapper.emitted('update:statement')).toBeFalsy() + }) + + it('disables reset and avoids rewriting invalid live dsl', async () => { + const invalidStatement = 'GET /logs/_search\n{\n "query": ' + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: invalidStatement, + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + const resetButton = wrapper.get('.elastic-reset-btn') + expect(resetButton.attributes('disabled')).toBeDefined() + + await resetButton.trigger('click') + + expect(wrapper.emitted('update:statement')).toBeFalsy() + }) + + it('commits multi-value contains tags on Enter and emits grouped should clauses', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['tag'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('contains') + + const valueInput = wrapper.get('[data-testid="elastic-dsl-filter-value"]') + await valueInput.setValue('tag-12') + await valueInput.trigger('keydown.enter') + await wrapper.vm.$nextTick() + await valueInput.setValue('tag-33') + await valueInput.trigger('keydown.enter') + await wrapper.vm.$nextTick() + + expect(wrapper.findAll('.elastic-dsl-value-token')).toHaveLength(2) + + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + bool: { + should: [ + { + match: { + tag: 'tag-12', + }, + }, + { + match: { + tag: 'tag-33', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ]) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(1) + expect(chips[0]?.text()).toContain('tag') + expect(chips[0]?.text()).toContain('tag-12, tag-33') + }) + + it('commits multi-value not_contains tags on Enter and emits grouped must_not clauses', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('not_contains') + + const valueInput = wrapper.get('[data-testid="elastic-dsl-filter-value"]') + await valueInput.setValue('error') + await valueInput.trigger('keydown.enter') + await wrapper.vm.$nextTick() + await valueInput.setValue('fatal') + await valueInput.trigger('keydown.enter') + await wrapper.vm.$nextTick() + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + bool: { + must_not: [ + { + bool: { + should: [ + { + match: { + message: 'error', + }, + }, + { + match: { + message: 'fatal', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ]) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(1) + expect(chips[0]?.text()).toContain('message') + expect(chips[0]?.text()).toContain('error, fatal') + }) + + it('renders the live dsl drawer as a syntax-highlighted code surface without clipping wrapped json clauses', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{\n "query": {\n "match_all": {}\n },\n "size": 10\n}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + expect(wrapper.get('.elastic-dsl-editor-highlight').html()).toContain('elastic-dsl-json-token-key') + expect(wrapper.get('.elastic-dsl-editor-highlight').html()).toContain('elastic-dsl-json-token-number') + expect(wrapper.find('.elastic-dsl-editor-pane').exists()).toBe(true) + expect(wrapper.find('.elastic-dsl-editor-scrollbar-mask').exists()).toBe(true) + expect(wrapper.find('.elastic-dsl-line-numbers-inner').exists()).toBe(true) + expect(wrapper.get('.elastic-dsl-editor').attributes('wrap')).not.toBe('off') + }) + + it('resets the live dsl viewport and caret to the structural start after builder writes new json', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'POST /logs/_search\n{\n "query": {\n "match_all": {}\n }\n}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + attachTo: document.body, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const editor = wrapper.get('.elastic-dsl-editor').element as HTMLTextAreaElement + editor.focus() + editor.setSelectionRange(editor.value.length, editor.value.length) + editor.scrollTop = 66 + editor.dispatchEvent(new Event('scroll')) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-field-option-message"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('seed') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + await nextTick() + await nextTick() + + expect(editor.scrollTop).toBe(0) + expect(editor.selectionStart).toBe(0) + expect(editor.selectionEnd).toBe(0) + expect(document.activeElement).toBe(wrapper.get('[data-testid="elastic-dsl-add-filter"]').element) + }) + + it('reapplies the structural-start viewport after a delayed browser scroll restoration during builder rewrites', async () => { + const originalRequestAnimationFrame = window.requestAnimationFrame + const originalCancelAnimationFrame = window.cancelAnimationFrame + const rafCallbacks = new Map() + let nextRafId = 1 + + const flushRafQueue = async () => { + while (rafCallbacks.size) { + const pending = [...rafCallbacks.values()] + rafCallbacks.clear() + pending.forEach((callback) => callback(0)) + await nextTick() + } + } + + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + const rafId = nextRafId++ + rafCallbacks.set(rafId, callback) + return rafId + }) as typeof window.requestAnimationFrame + window.cancelAnimationFrame = ((rafId: number) => { + rafCallbacks.delete(rafId) + }) as typeof window.cancelAnimationFrame + + try { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'POST /logs/_search\n{\n "query": {\n "match_all": {}\n }\n}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + attachTo: document.body, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const editor = wrapper.get('.elastic-dsl-editor').element as HTMLTextAreaElement + editor.focus() + editor.setSelectionRange(editor.value.length, editor.value.length) + editor.scrollTop = 66 + editor.dispatchEvent(new Event('scroll')) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-field-option-message"]').trigger('click') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('seed') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + await nextTick() + + editor.scrollTop = 54 + editor.setSelectionRange(editor.value.length, editor.value.length) + editor.dispatchEvent(new Event('scroll')) + + await flushRafQueue() + await nextTick() + + expect(editor.scrollTop).toBe(0) + expect(editor.selectionStart).toBe(0) + expect(editor.selectionEnd).toBe(0) + } finally { + window.requestAnimationFrame = originalRequestAnimationFrame + window.cancelAnimationFrame = originalCancelAnimationFrame + } + }) + + it('resets the live dsl viewport when the parent rewrites statement props with new json', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'POST /logs/_search\n{\n "query": {\n "match_all": {}\n }\n}', + selectedTargetPath: 'logs', + availableFields: ['message'], + canExecute: true, + canBeautify: true, + }, + attachTo: document.body, + }) + + await wrapper.get('#elastic-live-dsl-toggle').setValue(true) + + const editor = wrapper.get('.elastic-dsl-editor').element as HTMLTextAreaElement + editor.focus() + editor.setSelectionRange(editor.value.length, editor.value.length) + editor.scrollTop = 132 + editor.dispatchEvent(new Event('scroll')) + + await wrapper.setProps({ + statement: [ + 'POST /logs/_search', + JSON.stringify( + { + size: 50, + query: { + bool: { + must: [{ match_all: {} }], + filter: [ + { + bool: { + should: [ + { match: { message: 'seed' } }, + { match: { message: 'doc' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n'), + }) + await nextTick() + await nextTick() + + expect(editor.scrollTop).toBe(0) + expect(editor.selectionStart).toBe(0) + expect(editor.selectionEnd).toBe(0) + }) + + it('does not collapse richer bool clauses into a removable should-group chip', () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { + bool: { + must: [{ exists: { field: 'tenant' } }], + should: [{ match: { message: 'error' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['message', 'tenant'], + canExecute: true, + canBeautify: true, + }, + }) + + expect(wrapper.findAll('.elastic-dsl-chip')).toHaveLength(0) + expect(wrapper.text()).toContain('Builder has unsupported clauses') + }) + + it('renders and removes a supported top-level match query as a builder chip', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + match: { + room_name: 'dylan', + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['room_name'], + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(1) + expect(chips[0]?.text()).toContain('room_name') + expect(chips[0]?.text()).toContain('dylan') + + await chips[0]!.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query).toEqual({ match_all: {} }) + }) + + it('renders supported bool.must term and range clauses as builder chips', () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [ + { + term: { + status: 'active', + }, + }, + { + range: { + score: { + gte: 10, + }, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['status', 'score'], + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(2) + expect(chips[0]?.text()).toContain('status') + expect(chips[0]?.text()).toContain('active') + expect(chips[1]?.text()).toContain('score') + expect(chips[1]?.text()).toContain('10') + }) + + it('treats multi-bound range clauses as unsupported and preserves them when resetting visible chips', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [ + { + term: { + status: 'active', + }, + }, + { + range: { + score: { + gte: 10, + lt: 20, + }, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['status', 'score'], + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(1) + expect(chips[0]?.text()).toContain('status') + expect(wrapper.text()).toContain('Builder has unsupported clauses') + + await wrapper.get('.elastic-reset-btn').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.must).toEqual([ + { + range: { + score: { + gte: 10, + lt: 20, + }, + }, + }, + ]) + }) + + it('renders term object values from their value payload instead of object stringification', () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [ + { + term: { + status: { + value: 'active', + boost: 2, + }, + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(1) + expect(chips[0]?.text()).toContain('active') + expect(chips[0]?.text()).not.toContain('[object Object]') + }) + + it('supports not_exists operator without value input', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('not_exists') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + bool: { + must_not: [ + { + exists: { + field: 'status', + }, + }, + ], + }, + }, + ]) + }) + + it('supports wildcard filter clause', async () => { + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: 'GET /logs/_search\n{}', + selectedTargetPath: 'logs', + availableFields: ['user.name'], + canExecute: true, + canBeautify: true, + }, + }) + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await wrapper.get('.elastic-dsl-filter-operator-select').setValue('wildcard') + await wrapper.get('[data-testid="elastic-dsl-filter-value"]').setValue('jo*') + await wrapper.get('[data-testid="elastic-dsl-apply-filter"]').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + wildcard: { + 'user.name': 'jo*', + }, + }, + ]) + }) + + it('parses bool.must_not term as != chip and removes by original index', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { + bool: { + must_not: [ + { + term: { status: 'disabled' }, + }, + ], + }, + }, + { term: { level: 'error' } }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(2) + const notEqualChip = chips.find((chip) => chip.text().includes('status')) + expect(notEqualChip).toBeTruthy() + await notEqualChip!.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([{ term: { level: 'error' } }]) + }) + + it('preserves remaining must_not clauses when removing one negated chip', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { + bool: { + must_not: [ + { + term: { status: 'disabled' }, + }, + { + exists: { field: 'archived_at' }, + }, + ], + }, + }, + { term: { level: 'error' } }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(3) + const statusChip = chips.find((chip) => chip.text().includes('status')) + expect(statusChip).toBeTruthy() + await statusChip!.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + bool: { + must_not: [ + { + exists: { field: 'archived_at' }, + }, + ], + }, + }, + { term: { level: 'error' } }, + ]) + }) + + it('keeps sibling bool clauses when removing the last must_not chip', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { + bool: { + must_not: [ + { + term: { status: 'disabled' }, + }, + ], + should: [ + { + term: { priority: 'high' }, + }, + ], + minimum_should_match: 1, + }, + }, + { term: { level: 'error' } }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + const chips = wrapper.findAll('.elastic-dsl-chip') + expect(chips).toHaveLength(2) + expect(wrapper.text()).toContain('Builder has unsupported clauses') + const statusChip = chips.find((chip) => chip.text().includes('status')) + expect(statusChip).toBeTruthy() + await statusChip!.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query.bool.filter).toEqual([ + { + bool: { + should: [ + { + term: { priority: 'high' }, + }, + ], + minimum_should_match: 1, + }, + }, + { term: { level: 'error' } }, + ]) + }) + + it('resets visible filter chips in descending raw-index order so mixed filter chips all clear', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + filter: [ + { + bool: { + must_not: [ + { + term: { status: 'disabled' }, + }, + ], + }, + }, + { term: { level: 'error' } }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + canExecute: true, + canBeautify: true, + }, + }) + + expect(wrapper.findAll('.elastic-dsl-chip')).toHaveLength(2) + + await wrapper.get('.elastic-reset-btn').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query).toEqual({ match_all: {} }) + }) + + it('replaces an emptied builder query with match_all when removing the last chip', async () => { + const initialStatement = [ + 'GET /logs/_search', + JSON.stringify( + { + query: { + bool: { + must: [ + { + term: { + status: 'active', + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ].join('\n') + + const wrapper = mount(ConsoleElasticDslWorkspace, { + props: { + statement: initialStatement, + selectedTargetPath: 'logs', + availableFields: ['status'], + canExecute: true, + canBeautify: true, + }, + }) + + const chip = wrapper.get('.elastic-dsl-chip') + await chip.get('.chip-remove').trigger('click') + + const updateEvents = wrapper.emitted('update:statement') || [] + expect(updateEvents.length).toBeGreaterThan(0) + const latestStatement = String(updateEvents[updateEvents.length - 1]?.[0] || '') + const updatedBody = parseStatementBody(latestStatement) + expect(updatedBody.query).toEqual({ match_all: {} }) + }) +}) diff --git a/frontend/src/__tests__/console-elastic-filter-toolbar.test.ts b/frontend/src/__tests__/console-elastic-filter-toolbar.test.ts new file mode 100644 index 0000000..48874fa --- /dev/null +++ b/frontend/src/__tests__/console-elastic-filter-toolbar.test.ts @@ -0,0 +1,55 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_es' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView elastic result toolbar', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue(['logs']) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not render the sql result filter toolbar for elastic consoles before search runs', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elastic', + type: 'elasticsearch', + host: '192.168.50.201', + port: 30920, + options: {}, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.find('[data-testid="result-filter-trigger"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-filter-search"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-filter-export"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="elastic-dsl-add-filter"]').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/console-elastic-results-workspace.test.ts b/frontend/src/__tests__/console-elastic-results-workspace.test.ts new file mode 100644 index 0000000..dae6137 --- /dev/null +++ b/frontend/src/__tests__/console-elastic-results-workspace.test.ts @@ -0,0 +1,370 @@ +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import ConsoleElasticResultsWorkspace from '@/views/console/components/elastic-results/ConsoleElasticResultsWorkspace.vue' + +describe('ConsoleElasticResultsWorkspace', () => { + it('renders stitch-style table rows and metadata toggle controls', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + _id: 'doc-1', + _index: 'demo', + '@timestamp': '2026-02-26T15:56:41.452Z', + type: 'Update', + status: 'Success', + message: 'User performed update', + }, + }, + ], + total: 1, + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.find('.elastic-results-table').exists()).toBe(true) + expect(wrapper.text()).toContain('Update') + expect(wrapper.text()).toContain('Success') + + await wrapper.get('[data-testid="elastic-expand-all"]').trigger('click') + expect(wrapper.text()).toContain('View Metadata') + await wrapper.get('[data-testid="elastic-row-toggle-meta-0"]').trigger('click') + expect(wrapper.text()).toContain('Hide Metadata') + }) + + it('formats document source lazily after rows are expanded', async () => { + const formatJson = vi.fn((value: any) => JSON.stringify(value)) + + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { idx: 0, row: { _id: '1', _index: 'demo', title: 'Mock doc A', score: 1 } }, + { idx: 1, row: { _id: '2', _index: 'demo', title: 'Mock doc B', score: 0.9 } }, + ], + total: 2, + formatJson, + }, + }) + + expect(formatJson).toHaveBeenCalledTimes(0) + + await wrapper.get('[data-testid="elastic-expand-all"]').trigger('click') + + expect(formatJson).toHaveBeenCalledTimes(2) + }) + + it('reuses cached row json for expanded cards across reactive updates', async () => { + const formatJson = vi.fn((value: any) => JSON.stringify(value)) + + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { idx: 0, row: { _id: '1', _index: 'demo', title: 'Mock doc A', score: 1 } }, + { idx: 1, row: { _id: '2', _index: 'demo', title: 'Mock doc B', score: 0.9 } }, + ], + total: 2, + formatJson, + }, + }) + + await wrapper.get('[data-testid="elastic-expand-all"]').trigger('click') + expect(formatJson).toHaveBeenCalledTimes(2) + + await wrapper.get('[data-testid="elastic-row-toggle-meta-0"]').trigger('click') + await wrapper.get('[data-testid="elastic-row-toggle-meta-0"]').trigger('click') + + expect(formatJson).toHaveBeenCalledTimes(2) + }) + + it('renders list columns from selected fields instead of fixed defaults', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + _id: 'doc-1', + entity_id: 'u-1001', + source_system: 'pg', + message: 'updated', + }, + }, + ], + total: 1, + visibleFields: ['entity_id', 'source_system'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + const headers = wrapper.findAll('.elastic-results-table--head thead th').map((item) => item.text().trim()) + expect(headers).toContain('ENTITY ID') + expect(headers).toContain('SOURCE SYSTEM') + expect(wrapper.text()).toContain('u-1001') + expect(wrapper.text()).toContain('pg') + expect(wrapper.text()).not.toContain('@TIMESTAMP') + }) + + it('toggles per-row detail content from table row controls', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { idx: 0, row: { _id: 'same-id', _index: 'index-a', title: 'Alpha doc' } }, + { idx: 1, row: { _id: 'same-id', _index: 'index-b', title: 'Beta doc' } }, + ], + total: 2, + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.findAll('.elastic-results-row-detail')).toHaveLength(0) + await wrapper.get('[data-testid="elastic-row-toggle-0"]').trigger('click') + + const details = wrapper.findAll('.elastic-results-row-detail') + expect(details).toHaveLength(1) + expect(details[0]!.text()).toContain('Alpha doc') + expect(details[0]!.text()).not.toContain('Beta doc') + }) + + it('renders svg icons for toolbar and row toggle controls instead of text glyphs', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { idx: 0, row: { _id: 'doc-1', _index: 'demo', title: 'Alpha doc' } }, + ], + total: 1, + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.find('[data-testid="elastic-expand-all"] svg').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-export-all"] svg').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-row-toggle-0"] svg').exists()).toBe(true) + }) + + it('renders elastic table headers in a dedicated head strip and syncs horizontal body scrolling', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + attachTo: document.body, + props: { + rows: Array.from({ length: 8 }, (_, idx) => ({ + idx, + row: { + _id: `doc-${idx + 1}`, + _index: 'demo', + action: idx % 2 === 0 ? 'query' : 'update', + detail: `audit detail ${idx + 1}`, + }, + })), + total: 8, + visibleFields: ['action', 'detail'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.find('.elastic-results-table-head-wrap').exists()).toBe(true) + expect(wrapper.find('.elastic-results-table--body thead').exists()).toBe(false) + + const wrap = wrapper.get('.elastic-results-table-wrap') + const headWrap = wrapper.get('.elastic-results-table-head-wrap') + + Object.defineProperty(wrap.element, 'scrollLeft', { + value: 96, + writable: true, + configurable: true, + }) + + await wrap.trigger('scroll') + await nextTick() + + expect((headWrap.element as HTMLElement).scrollLeft).toBe(96) + + wrapper.unmount() + }) + + it('opens a cell context menu and emits the full raw value for copy', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + attachTo: document.body, + props: { + rows: [ + { + idx: 0, + row: { + _id: 'doc-1', + _index: 'demo', + message: '0123456789abcdefghijklmnopqrstuvwxyz-raw-value', + }, + }, + ], + total: 1, + visibleFields: ['message'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + await wrapper.get('.elastic-result-cell').trigger('contextmenu', { + clientX: 120, + clientY: 70, + }) + + expect(wrapper.find('[data-testid="elastic-cell-context-menu"]').exists()).toBe(true) + + await wrapper.get('[data-testid="elastic-cell-copy-raw"]').trigger('click') + + expect(wrapper.emitted('copy-cell')).toEqual([ + ['0123456789abcdefghijklmnopqrstuvwxyz-raw-value'], + ]) + + wrapper.unmount() + }) + + it('applies semantic value pill styles for number, boolean, array and object fields', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + count: 12, + active: true, + tags: ['red', 'blue'], + payload: { source: 'api' }, + }, + }, + ], + total: 1, + visibleFields: ['count', 'active', 'tags', 'payload'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.find('.elastic-value-pill--number').exists()).toBe(true) + expect(wrapper.find('.elastic-value-pill--boolean').exists()).toBe(true) + expect(wrapper.find('.elastic-value-pill--array').exists()).toBe(true) + expect(wrapper.find('.elastic-value-pill--object').exists()).toBe(true) + }) + + it('shrinks short scalar columns while keeping longer text columns wider', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + sequence: '42', + message: '0123456789abcdefghijklmnopqrstuvwxyz-raw-value', + }, + }, + ], + total: 1, + visibleFields: ['sequence', 'message'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + const headers = wrapper.findAll('.elastic-results-table--head thead th') + expect(headers[1]!.classes()).toContain('elastic-result-head--width-xs') + expect(headers[2]!.classes()).toContain('elastic-result-head--width-lg') + + const cells = wrapper.findAll('.elastic-result-cell') + expect(cells[0]!.classes()).toContain('elastic-result-cell--width-xs') + expect(cells[1]!.classes()).toContain('elastic-result-cell--width-lg') + }) + + it('classifies formatted string values into richer semantic styles even without field-name hints', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + ref: '00000000-0000-0000-0000-000000001001', + createdLabel: '2026-03-06T13:58:18.506Z', + mode: 'sync', + outcomeLabel: 'warning', + scope: 'prod-eu-1', + }, + }, + ], + total: 1, + visibleFields: ['ref', 'createdLabel', 'mode', 'outcomeLabel', 'scope'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.find('.elastic-value-pill--identifier').exists()).toBe(true) + expect(wrapper.find('.elastic-value-pill--timestamp').exists()).toBe(true) + expect(wrapper.find('.type-pill').exists()).toBe(true) + expect(wrapper.find('.status-pill').exists()).toBe(true) + expect(wrapper.find('.elastic-value-pill--keyword').exists()).toBe(true) + }) + + it('truncates long plain-text values to 30 characters while keeping the full title tooltip', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + message: '0123456789abcdefghijklmnopqrstuvwxyz-raw-value', + }, + }, + ], + total: 1, + visibleFields: ['message'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + const cell = wrapper.get('.elastic-result-cell') + expect(cell.attributes('title')).toBe('0123456789abcdefghijklmnopqrstuvwxyz-raw-value') + expect(cell.text()).toBe('0123456789abcdefghijklmnopqrst...') + }) + + it('keeps long field labels from collapsing into xs columns when the cell values are short', () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + subscription_status_reason: 'ok', + }, + }, + ], + total: 1, + visibleFields: ['subscription_status_reason'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + const header = wrapper.get('.elastic-results-table--head thead th:not(.elastic-col-toggle)') + expect(header.classes()).toContain('elastic-result-head--width-md') + }) + + it('toggles row detail when any data cell is clicked', async () => { + const wrapper = mount(ConsoleElasticResultsWorkspace, { + props: { + rows: [ + { + idx: 0, + row: { + _id: 'doc-1', + _index: 'demo', + message: 'Alpha doc', + }, + }, + ], + total: 1, + visibleFields: ['message'], + formatJson: (value: any) => JSON.stringify(value, null, 2), + }, + }) + + expect(wrapper.findAll('.elastic-results-row-detail')).toHaveLength(0) + await wrapper.get('.elastic-result-cell').trigger('click') + expect(wrapper.findAll('.elastic-results-row-detail')).toHaveLength(1) + }) +}) diff --git a/frontend/src/__tests__/console-elasticsearch-entity-expand.test.ts b/frontend/src/__tests__/console-elasticsearch-entity-expand.test.ts new file mode 100644 index 0000000..0772d3f --- /dev/null +++ b/frontend/src/__tests__/console-elasticsearch-entity-expand.test.ts @@ -0,0 +1,296 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_es' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView Elasticsearch entity details', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if ((statement || '').includes('/_cat/indices?format=json')) { + return { + columns: [], + rows: [{ index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { columns: [], rows: [], rowCount: 0, elapsedMs: 12 } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'title', dataType: 'text', nullable: '-' }], + indexes: [], + details: [ + { label: 'Index', value: 'futrixdata-demo-1' }, + { label: 'Health', value: 'green' }, + { label: 'Docs', value: 123 }, + ], + } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders stitch-style expandable index fields filter while keeping index store size', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('green') + expect(wrapper.text()).toContain('12mb') + + await wrapper.get('.entity-item').trigger('click') + await flushPromises() + + const toggle = wrapper.find('.entity-toggle') + expect(toggle.exists()).toBe(true) + await toggle.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-index-fields-filter-futrixdata-demo-1"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Fields') + expect(wrapper.text()).toContain('title') + + await wrapper.get('[data-testid="elastic-index-fields-filter-futrixdata-demo-1"]').setValue('ti') + await flushPromises() + expect(wrapper.text()).toContain('title') + }) + + it('persists sanitized elastic field selections after mappings remove stale fields', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + store.elasticsearchFieldSelections['futrixdata-demo-1'] = ['title', 'stale_field'] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await wrapper.get('.entity-item').trigger('click') + await flushPromises() + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + expect(store.elasticsearchFieldSelections['futrixdata-demo-1']).toEqual(['title']) + }) + + it('defaults elastic field selections to all mapped fields when an index expands', async () => { + ;(api.describeEntity as any).mockResolvedValue({ + columns: [ + { name: 'title', dataType: 'text', nullable: '-' }, + { name: 'user.id', dataType: 'keyword', nullable: '-' }, + { name: 'status', dataType: 'keyword', nullable: '-' }, + { name: 'source', dataType: 'keyword', nullable: '-' }, + { name: 'action', dataType: 'keyword', nullable: '-' }, + { name: 'event_id', dataType: 'keyword', nullable: '-' }, + { name: 'created_at', dataType: 'date', nullable: '-' }, + ], + indexes: [], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await wrapper.get('.entity-item').trigger('click') + await flushPromises() + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + expect(store.elasticsearchFieldSelections['futrixdata-demo-1']).toEqual([ + 'title', + 'user.id', + 'status', + 'source', + 'action', + 'event_id', + 'created_at', + ]) + }) + + it('keeps cached selections for indices whose mappings are not loaded yet', async () => { + ;(api.executeStatement as any).mockImplementation(async (_id: string, statement: string) => { + if ((statement || '').includes('/_cat/indices?format=json')) { + return { + columns: [], + rows: [ + { index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }, + { index: 'futrixdata-demo-2', health: 'yellow', 'store.size': '48mb' }, + ], + rowCount: 2, + elapsedMs: 12, + } as any + } + return { columns: [], rows: [], rowCount: 0, elapsedMs: 12 } as any + }) + ;(api.describeEntity as any).mockImplementation(async (_id: string, entity: string) => { + if (entity === 'futrixdata-demo-1') { + return { + columns: [{ name: 'title', dataType: 'text', nullable: '-' }], + indexes: [], + details: [], + } as any + } + return { + columns: [], + indexes: [], + details: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + store.elasticsearchFieldSelections['futrixdata-demo-2'] = ['user.id'] + await flushPromises() + + await wrapper.findAll('.entity-item')[0]!.trigger('click') + await flushPromises() + await wrapper.findAll('.entity-toggle')[0]!.trigger('click') + await flushPromises() + + expect(store.elasticsearchFieldSelections['futrixdata-demo-2']).toEqual(['user.id']) + }) + + it('restores elastic field selections from datasource snapshot when the live entry is missing', async () => { + ;(api.describeEntity as any).mockResolvedValue({ + columns: [ + { name: 'title', dataType: 'text', nullable: '-' }, + { name: 'message', dataType: 'text', nullable: '-' }, + ], + indexes: [], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + store.elasticsearchFieldSelectionsByDatasource['ds_es'] = { + 'futrixdata-demo-1': ['title'], + } + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + delete store.elasticsearchFieldSelections['futrixdata-demo-1'] + await flushPromises() + + expect(store.elasticsearchFieldSelections['futrixdata-demo-1']).toBeUndefined() + + await wrapper.get('.entity-toggle').trigger('click') + await flushPromises() + + expect(store.elasticsearchFieldSelections['futrixdata-demo-1']).toEqual(['title']) + const fieldRows = wrapper.findAll('.es-index-field-item') + expect(fieldRows).toHaveLength(2) + const titleRow = fieldRows.find((item) => item.text().includes('title')) + const messageRow = fieldRows.find((item) => item.text().includes('message')) + expect((titleRow!.get('input').element as HTMLInputElement).checked).toBe(true) + expect((messageRow!.get('input').element as HTMLInputElement).checked).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-elasticsearch-json-view.test.ts b/frontend/src/__tests__/console-elasticsearch-json-view.test.ts new file mode 100644 index 0000000..0dc6de6 --- /dev/null +++ b/frontend/src/__tests__/console-elasticsearch-json-view.test.ts @@ -0,0 +1,85 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getDatasourceTypeIconUrl } from '@/modules/datasource/icons' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_es' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView Elasticsearch results', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if ((statement || '').includes('/_cat/indices?format=json')) { + return { + columns: [], + rows: [{ index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [{ _id: '1', _index: 'futrixdata-demo-1', _source: { title: 'doc' } }], + rowCount: 1, + elapsedMs: 12, + } as any + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders dedicated elastic results workspace with list/raw controls in parity mode', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_es', + name: 'Elasticsearch', + type: 'elasticsearch', + host: 'localhost', + port: 9200, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.get('[data-testid="entity-panel-header-label"]').text()).toBe('Elasticsearch') + expect(wrapper.get('[data-testid="entity-panel-header-icon"]').attributes('src')).toBe( + getDatasourceTypeIconUrl('elasticsearch'), + ) + expect(wrapper.find('#entity-kind').exists()).toBe(false) + + await getConsoleStatementInput(wrapper).setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-view-list"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-view-raw"]').exists()).toBe(true) + expect(wrapper.find('.sql-editor-json-tree-wrap').exists()).toBe(false) + expect(wrapper.find('.result-table-shell').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-elasticsearch-paging.test.ts b/frontend/src/__tests__/console-elasticsearch-paging.test.ts new file mode 100644 index 0000000..859a519 --- /dev/null +++ b/frontend/src/__tests__/console-elasticsearch-paging.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' + +import { + buildElasticSearchPitOpenStatement, + buildElasticSearchSearchAfterStatement, + getElasticSearchDeepPaginationSupport, + getElasticSearchAccessiblePageCount, + getElasticSearchPagingPatchForPage, + patchElasticSearchStatementForPaging, +} from '@/views/console/utils/elasticSearchPaging' + +describe('patchElasticSearchStatementForPaging', () => { + it('patches from/size without rewriting large JSON integers', () => { + const largeInt = '9223372036854775807' + const statement = `POST /demo/_search\n{\n \"query\": {\n \"term\": { \"id\": ${largeInt} }\n }\n}` + const patched = patchElasticSearchStatementForPaging(statement, { from: 50, size: 25 }) + + expect(patched).not.toBeNull() + expect(String(patched)).toContain('/demo/_search?') + expect(String(patched)).toContain('from=50') + expect(String(patched)).toContain('size=25') + expect(String(patched)).toContain(largeInt) + expect(String(patched)).not.toContain('9223372036854776000') + }) + + it('supports semicolon-terminated request lines', () => { + const statement = `POST /demo/_search;\n{\n \"query\": { \"match_all\": {} }\n}` + const patched = patchElasticSearchStatementForPaging(statement, { from: 10, size: 5 }) + + expect(patched).not.toBeNull() + expect(String(patched)).toContain('/demo/_search?') + expect(String(patched)).toContain('from=10') + expect(String(patched)).toContain('size=5') + expect(String(patched).split('\n')[0]).toContain(';') + }) + + it('returns null for non-search requests', () => { + expect(patchElasticSearchStatementForPaging('GET /_cat/indices?v', { from: 0, size: 10 })).toBeNull() + }) + + it('detects deep pagination support for single-target search statements', () => { + expect(getElasticSearchDeepPaginationSupport('GET /demo/_search\n{}')).toEqual({ + supported: true, + target: 'demo', + }) + + expect(getElasticSearchDeepPaginationSupport('GET /_search\n{}')).toEqual({ + supported: false, + target: '', + }) + }) + + it('builds point-in-time open statements for explicit targets', () => { + expect(buildElasticSearchPitOpenStatement('demo', '2m')).toBe('POST /demo/_pit?keep_alive=2m') + }) + + it('builds search_after requests with a pit and default _shard_doc sort', () => { + const statement = 'GET /demo/_search\n{}' + const patched = buildElasticSearchSearchAfterStatement(statement, { + pitId: 'pit-1', + keepAlive: '1m', + size: 50, + searchAfter: [9950], + trackTotalHits: false, + sourceMode: 'none', + }) + + expect(patched).not.toBeNull() + expect(String(patched).split('\n')[0]).toBe('POST /_search') + expect(String(patched)).toContain('"pit":{"id":"pit-1","keep_alive":"1m"}') + expect(String(patched)).toContain('"search_after":[9950]') + expect(String(patched)).toContain('"sort":[{"_shard_doc":"asc"}]') + expect(String(patched)).toContain('"track_total_hits":false') + expect(String(patched)).toContain('"_source":false') + }) + + it('preserves explicit body sort clauses in search_after requests', () => { + const statement = 'POST /demo/_search\n{"sort":[{"created_at":"desc"}]}' + const patched = buildElasticSearchSearchAfterStatement(statement, { + pitId: 'pit-1', + keepAlive: '1m', + size: 50, + searchAfter: [9950], + }) + + expect(patched).not.toBeNull() + expect(String(patched)).toContain('"sort":[{"created_at":"desc"}]') + expect(String(patched)).not.toContain('"_shard_doc"') + }) + + it('preserves large JSON integer literals in search_after requests', () => { + const largeInt = '9223372036854775807' + const statement = `POST /demo/_search\n{\n "query": {\n "term": { "id": ${largeInt} }\n }\n}` + const patched = buildElasticSearchSearchAfterStatement(statement, { + pitId: 'pit-1', + keepAlive: '1m', + size: 50, + searchAfter: [9950], + }) + + expect(patched).not.toBeNull() + expect(String(patched)).toContain(largeInt) + expect(String(patched)).not.toContain('9223372036854776000') + }) + + it('caps accessible pages by the elasticsearch result window', () => { + expect( + getElasticSearchAccessiblePageCount({ + total: 100000, + baseFrom: 0, + pageSize: 50, + }), + ).toBe(200) + }) + + it('floors accessible pages when the starting offset is not aligned to the page size', () => { + expect( + getElasticSearchAccessiblePageCount({ + total: 10000, + baseFrom: 10, + pageSize: 50, + }), + ).toBe(199) + }) + + it('refuses to build a page request beyond the accessible result window', () => { + expect( + getElasticSearchPagingPatchForPage({ + page: 200, + total: 10000, + baseFrom: 10, + pageSize: 50, + }), + ).toBeNull() + + expect( + getElasticSearchPagingPatchForPage({ + page: 201, + total: 100000, + baseFrom: 0, + pageSize: 50, + }), + ).toBeNull() + }) +}) diff --git a/frontend/src/__tests__/console-empty-results.test.ts b/frontend/src/__tests__/console-empty-results.test.ts new file mode 100644 index 0000000..f774507 --- /dev/null +++ b/frontend/src/__tests__/console-empty-results.test.ts @@ -0,0 +1,70 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView empty results', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows: [], + rowCount: 0, + elapsedMs: 12, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows 0 rows instead of "No results yet."', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users WHERE 1 = 0;') + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + await executeButton.trigger('click') + await flushPromises() + + expect(wrapper.find('#result').classes()).toContain('result--sql') + expect(wrapper.find('#result-meta').text()).toContain('Rows: 0') + expect(wrapper.find('#result').text()).toContain('0 rows') + expect(wrapper.find('#result').text()).not.toContain('No results yet.') + }) +}) diff --git a/frontend/src/__tests__/console-entities-mongo-parity-seed.test.ts b/frontend/src/__tests__/console-entities-mongo-parity-seed.test.ts new file mode 100644 index 0000000..d1282c7 --- /dev/null +++ b/frontend/src/__tests__/console-entities-mongo-parity-seed.test.ts @@ -0,0 +1,74 @@ +import { computed, ref } from 'vue' +import { describe, expect, it, vi } from 'vitest' + +import { useConsoleEntities } from '@/views/console/composables/useConsoleEntities' + +describe('useConsoleEntities mongodb parity seeding', () => { + it('uses browse statement builder so first page matches pager query semantics', async () => { + const store = { + current: { id: 'ds_mongo', type: 'mongodb' }, + selectedEntity: '', + mongoDatabase: 'admin', + entities: [], + elasticsearchIndexMeta: {}, + setNotice: vi.fn(), + } as any + + const entityPattern = ref('') + const entityDetail = ref(null) + const templateTarget = ref('') + const statement = ref('') + const mongoBrowseActive = ref(false) + const mongoBrowseCollection = ref('') + const mongoPageIndex = ref(7) + + const fetchEntityDetails = vi.fn().mockResolvedValue({ + columns: [], + indexes: [], + details: [], + }) + const setStatementSilently = vi.fn((value: string) => { + statement.value = value + }) + const buildMongoBrowseStatement = vi + .fn() + .mockReturnValue('db.users.find({}, { sort: {_id: -1}, limit: 50 })') + const runStatement = vi.fn().mockResolvedValue(undefined) + + const entities = useConsoleEntities({ + store, + entityPattern, + entityDetail, + templateTarget, + statement, + isSqlEditorParity: computed(() => true), + isMongo: computed(() => true), + isSQL: computed(() => false), + isRedis: computed(() => false), + mongoDatabaseMode: computed(() => false), + loadMongoDatabases: vi.fn(), + loadRedisKeys: vi.fn(), + clearEntityDetailsCache: vi.fn(), + fetchEntityDetails, + setStatementSilently, + buildMongoBrowseStatement, + mongoBrowseActive, + mongoBrowseCollection, + mongoPageIndex, + resetSqlPaging: vi.fn(), + runStatement, + markActive: vi.fn(), + resetRedisFullPreview: vi.fn(), + }) + + await entities.describeEntity('users') + + expect(buildMongoBrowseStatement).toHaveBeenCalledWith('users') + expect(setStatementSilently).toHaveBeenCalledWith('db.users.find({}, { sort: {_id: -1}, limit: 50 })') + expect(statement.value).toBe('db.users.find({}, { sort: {_id: -1}, limit: 50 })') + expect(mongoPageIndex.value).toBe(0) + expect(mongoBrowseActive.value).toBe(true) + expect(mongoBrowseCollection.value).toBe('users') + expect(runStatement).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/console-entity-index-list-css.test.ts b/frontend/src/__tests__/console-entity-index-list-css.test.ts new file mode 100644 index 0000000..d26927c --- /dev/null +++ b/frontend/src/__tests__/console-entity-index-list-css.test.ts @@ -0,0 +1,96 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadIndexListCss = () => { + const filePath = path.resolve( + __dirname, + '..', + 'styles', + 'console', + 'columns-toolbar-mongo', + 'index-list.css', + ) + return readCssWithImports(filePath) +} + +const blockOf = (css: string, selector: string) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const re = new RegExp(`${escaped}\\s*\\{[\\s\\S]*?\\}`) + return css.match(re)?.[0] ?? '' +} + +describe('console entity index-list CSS (long-identifier + sectioned layout)', () => { + it('renders index rows as a 2-column grid that lets long names wrap', () => { + const css = loadIndexListCss() + const block = blockOf(css, '.index-row') + + expect(block).toContain('display: grid') + expect(block).toContain('grid-template-columns: auto 1fr') + // min-width: 0 on the grid item is required for flex/grid children to be allowed to shrink + // below their intrinsic content size — without it, long identifiers force horizontal overflow. + expect(block).toContain('min-width: 0') + }) + + it('keeps the row content column shrinkable so identifier names wrap on word boundaries', () => { + const css = loadIndexListCss() + const block = blockOf(css, '.index-row-content') + + expect(block).toContain('min-width: 0') + expect(block).toContain('display: grid') + }) + + it('wraps long identifier names without leaking outside the panel', () => { + const css = loadIndexListCss() + const block = blockOf(css, '.index-row-name') + + // Identifiers like fd_inventory_ledger_unique need both rules so the + // browser can break inside the token and on the hints we emit. + expect(block).toContain('word-break: break-word') + expect(block).toContain('overflow-wrap: anywhere') + }) + + it('wraps the fields list and lets each value shrink for long column names', () => { + const css = loadIndexListCss() + const fieldsBlock = blockOf(css, '.index-row-fields') + const valueBlock = blockOf(css, '.index-row-fields-value') + + expect(fieldsBlock).toContain('flex-wrap: wrap') + expect(fieldsBlock).toContain('min-width: 0') + expect(fieldsBlock).toContain('overflow-wrap: anywhere') + + expect(valueBlock).toContain('min-width: 0') + expect(valueBlock).toContain('word-break: break-word') + expect(valueBlock).toContain('overflow-wrap: anywhere') + }) + + it('separates sectioned index groups (table-keys vs secondary-indexes)', () => { + const css = loadIndexListCss() + // Adjacent-sibling spacing keeps the DDB "Table keys" and + // "Secondary indexes" groups visually separated. + expect(css).toMatch(/\.index-list-section\s*\+\s*\.index-list-section\s*\{[\s\S]*?margin-top:\s*8px/) + + const headBlock = blockOf(css, '.index-list-section-head') + expect(headBlock).toContain('text-transform: uppercase') + expect(headBlock).toContain('font-weight: 700') + }) + + it('highlights DDB key rows distinctly from generic index rows', () => { + const css = loadIndexListCss() + const block = blockOf(css, '.index-row.ddb-key-row') + + // Distinct border + background so partition / sort key rows stand out. + expect(block).toContain('border-color') + expect(block).toContain('background') + }) + + it('keeps the kind pill from collapsing or wrapping inside its column', () => { + const css = loadIndexListCss() + const block = blockOf(css, '.index-kind-pill') + + expect(block).toContain('flex: 0 0 auto') + expect(block).toContain('white-space: nowrap') + }) +}) diff --git a/frontend/src/__tests__/console-entity-list-paging.test.ts b/frontend/src/__tests__/console-entity-list-paging.test.ts new file mode 100644 index 0000000..61680aa --- /dev/null +++ b/frontend/src/__tests__/console-entity-list-paging.test.ts @@ -0,0 +1,161 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +let routeId = 'ds_mysql' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: routeId } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const mountConsoleView = async (pinia: ReturnType) => { + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + return wrapper +} + +const setEntityListScroll = async ( + wrapper: ReturnType, + opts: { scrollTop: number; clientHeight: number; scrollHeight: number }, +) => { + const listEl = wrapper.find('#entity-list').element as HTMLElement + Object.defineProperty(listEl, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true }) + Object.defineProperty(listEl, 'clientHeight', { value: opts.clientHeight, configurable: true }) + Object.defineProperty(listEl, 'scrollHeight', { value: opts.scrollHeight, configurable: true }) + await wrapper.find('#entity-list').trigger('scroll') + await flushPromises() +} + +describe('Console entity list paging (SQL + DynamoDB)', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads the next page on scroll for MySQL datasources', async () => { + routeId = 'ds_mysql' + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any, + ] + + const listSpy = vi + // @ts-expect-error - introduced by this feature + .spyOn(api, 'listEntitiesPage') + .mockResolvedValueOnce({ items: ['t1', 't2'], cursor: 't2', done: false }) + .mockResolvedValueOnce({ items: ['t3'], cursor: '', done: true }) + + const wrapper = await mountConsoleView(pinia) + + await setEntityListScroll(wrapper, { scrollTop: 900, clientHeight: 200, scrollHeight: 1000 }) + + expect(listSpy).toHaveBeenCalledTimes(2) + expect(listSpy.mock.calls[0]).toEqual(['ds_mysql', '', '', '', 200, '', false]) + expect(listSpy.mock.calls[1]).toEqual(['ds_mysql', '', '', 't2', 200, '', false]) + }) + + it('re-fetches from the backend when the filter changes (DynamoDB)', async () => { + vi.useFakeTimers() + routeId = 'ds_ddb' + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + options: {}, + } as any, + ] + + const listSpy = vi + // @ts-expect-error - introduced by this feature + .spyOn(api, 'listEntitiesPage') + .mockResolvedValueOnce({ items: ['alpha', 'beta'], cursor: 'beta', done: false }) + .mockResolvedValueOnce({ items: ['gamma'], cursor: '', done: true }) + + const wrapper = await mountConsoleView(pinia) + + await wrapper.find('#entity-pattern').setValue('gamma') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(listSpy).toHaveBeenCalledTimes(2) + expect(listSpy.mock.calls[0]).toEqual(['ds_ddb', '', '', '', 100, '', false]) + expect(listSpy.mock.calls[1]).toEqual(['ds_ddb', 'gamma', '', '', 100, '', false]) + + expect(wrapper.text()).toContain('gamma') + vi.useRealTimers() + }) + + it('re-fetches from the backend when the filter is cleared (PostgreSQL)', async () => { + vi.useFakeTimers() + routeId = 'ds_pg' + const store = useAppStore() + store.datasources = [ + { + id: 'ds_pg', + name: 'Postgres', + type: 'postgresql', + host: 'localhost', + port: 5432, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any, + ] + + const listSpy = vi + // @ts-expect-error - introduced by this feature + .spyOn(api, 'listEntitiesPage') + .mockResolvedValueOnce({ items: ['audit.table_0001', 'audit.table_0002'], cursor: 'audit.table_0002', done: false }) + .mockResolvedValueOnce({ items: ['public.table_0300'], cursor: '', done: true }) + .mockResolvedValueOnce({ items: ['audit.table_0001', 'audit.table_0002'], cursor: 'audit.table_0002', done: false }) + + const wrapper = await mountConsoleView(pinia) + + await wrapper.find('#entity-pattern').setValue('public.table_0300') + vi.advanceTimersByTime(300) + await flushPromises() + + await wrapper.find('#entity-pattern').setValue('') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(listSpy).toHaveBeenCalledTimes(3) + expect(listSpy.mock.calls[0]).toEqual(['ds_pg', '', '', '', 200, '', false]) + expect(listSpy.mock.calls[1]).toEqual(['ds_pg', 'public.table_0300', '', '', 200, '', false]) + expect(listSpy.mock.calls[2]).toEqual(['ds_pg', '', '', '', 200, '', false]) + expect(wrapper.text()).toContain('audit.table_0001') + vi.useRealTimers() + }) +}) diff --git a/frontend/src/__tests__/console-entity-panel-header.test.ts b/frontend/src/__tests__/console-entity-panel-header.test.ts new file mode 100644 index 0000000..36e022f --- /dev/null +++ b/frontend/src/__tests__/console-entity-panel-header.test.ts @@ -0,0 +1,155 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getDatasourceTypeIconUrl } from '@/modules/datasource/icons' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_console' }, query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +const makeDatasource = ( + type: 'mysql' | 'redis' | 'elasticsearch', + overrides: Record = {}, +) => ({ + id: 'ds_console', + name: type === 'redis' ? 'Cache Cluster' : type === 'elasticsearch' ? 'Search Cluster' : 'Production', + type, + host: 'localhost', + port: type === 'redis' ? 6379 : type === 'elasticsearch' ? 9200 : 3306, + username: '', + password: '', + database: type === 'mysql' ? 'futrixdata' : '', + authSource: '', + options: {}, + ...overrides, +}) + +describe('ConsoleView entity panel header', () => { + let pinia: ReturnType + let originalInnerWidth: number + + beforeEach(() => { + pinia = createPinia() + originalInnerWidth = window.innerWidth + setActivePinia(pinia) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [], + details: [], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 1, + } as any) + }) + + afterEach(() => { + window.innerWidth = originalInnerWidth + window.dispatchEvent(new Event('resize')) + vi.restoreAllMocks() + }) + + it('shows datasource type above the database name and only the shared icon refresh action for mysql', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.get('[data-testid="entity-panel-header-label"]').text()).toBe('MySQL') + expect(wrapper.get('[data-testid="entity-panel-header-meta"]').text()).toBe('futrixdata') + expect(wrapper.get('[data-testid="entity-panel-header-icon"]').attributes('src')).toBe(getDatasourceTypeIconUrl('mysql')) + expect(wrapper.find('#entity-kind').exists()).toBe(false) + expect(wrapper.get('[data-testid="entity-panel-refresh"]').attributes('aria-label')).toBe('Refresh') + expect(wrapper.text()).not.toContain('Refresh Entities') + }) + + it('uses datasource-type header text and keeps redis on the desktop shared width baseline', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis')] + window.innerWidth = 1280 + window.dispatchEvent(new Event('resize')) + + vi.mocked(api.listEntities).mockResolvedValue(['jobs:1'] as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.get('[data-testid="entity-panel-header-label"]').text()).toBe('Redis') + expect(wrapper.get('[data-testid="entity-panel-header-icon"]').attributes('src')).toBe(getDatasourceTypeIconUrl('redis')) + expect(wrapper.find('[data-testid="entity-panel-header-meta"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="entity-panel-header-label"]').text()).not.toBe('Cache Cluster') + expect(wrapper.get('[data-testid="redis-proto-keys"]').attributes('style')).toContain('width: 250px;') + }) + + it('shrinks redis to the same narrow-width left-rail cap used by sql datasources', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('redis')] + window.innerWidth = 790 + window.dispatchEvent(new Event('resize')) + + vi.mocked(api.listEntities).mockResolvedValue(['jobs:1'] as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.get('[data-testid="redis-proto-keys"]').attributes('style')).toContain('width: 150px;') + }) + + it('uses datasource-type header text for elasticsearch instead of indices and kind pills', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('elasticsearch')] + + vi.mocked(api.executeStatement).mockResolvedValue({ + columns: [], + rows: [{ index: 'logs-2026.03.08', health: 'green', 'store.size': '12mb' }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.get('[data-testid="entity-panel-header-label"]').text()).toBe('Elasticsearch') + expect(wrapper.get('[data-testid="entity-panel-header-icon"]').attributes('src')).toBe( + getDatasourceTypeIconUrl('elasticsearch'), + ) + expect(wrapper.find('[data-testid="entity-panel-header-meta"]').exists()).toBe(false) + expect(wrapper.find('#entity-kind').exists()).toBe(false) + expect(wrapper.find('#entity-title').text()).toBe('Elasticsearch') + }) +}) diff --git a/frontend/src/__tests__/console-explain-mysql-readable.test.ts b/frontend/src/__tests__/console-explain-mysql-readable.test.ts new file mode 100644 index 0000000..c39b558 --- /dev/null +++ b/frontend/src/__tests__/console-explain-mysql-readable.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { computed, ref } from 'vue' + +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' +import { useConsoleExplain } from '@/views/console/composables/useConsoleExplain' + +describe('useConsoleExplain mysql readable narrative', () => { + afterEach(() => { + resetAppI18nForTest() + }) + + it('explains index usage, estimated rows, likely rows, and special fields in plain language', () => { + setAppLocale('zh') + + const statement = ref('SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 100;') + const explainResult = ref({ + usesIndex: true, + indexes: ['idx_orders_user_created'], + stages: ['RANGE SCAN'], + detail: [ + { + id: 1, + select_type: 'SIMPLE', + table: 'orders', + type: 'range', + possible_keys: 'idx_orders_user_created,idx_orders_created', + key: 'idx_orders_user_created', + key_len: '8', + rows: '12000', + filtered: '5.00', + Extra: 'Using where; Using filesort', + }, + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'mysql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + const lines = explain.explainNarrativeLines.value + expect(lines).toContain('索引命中:是,命中 idx_orders_user_created。') + expect(lines).toContain('预计会扫描/处理约 12000 行数据。') + expect(lines).toContain('按 filtered 估算,实际可能操作约 600 行数据(rows × filtered%)。') + expect(lines).toContain('访问类型:RANGE(范围扫描,通常会用到索引区间)。') + expect(lines).toContain('候选索引:idx_orders_user_created、idx_orders_created。') + expect(lines).toContain('额外信息:Using where(先读取再过滤),Using filesort(需要额外排序,通常说明排序没走索引)。') + }) +}) diff --git a/frontend/src/__tests__/console-explain-postgres-json.test.ts b/frontend/src/__tests__/console-explain-postgres-json.test.ts new file mode 100644 index 0000000..331bdd2 --- /dev/null +++ b/frontend/src/__tests__/console-explain-postgres-json.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { computed, ref } from 'vue' + +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' +import { useConsoleExplain } from '@/views/console/composables/useConsoleExplain' + +describe('useConsoleExplain postgres json detail', () => { + afterEach(() => { + resetAppI18nForTest() + }) + + it('renders stage and index narrative from normalized postgres explain result', () => { + setAppLocale('en') + + const statement = ref('SELECT * FROM users ORDER BY id DESC LIMIT 50;') + const explainResult = ref({ + usesIndex: true, + indexes: ['users_pkey'], + stages: ['Limit', 'Index Scan Backward'], + totalDocsExamined: 10000, + detail: [ + { + Plan: { + 'Node Type': 'Limit', + 'Plan Rows': 50, + Plans: [ + { + 'Node Type': 'Index Scan Backward', + 'Index Name': 'users_pkey', + 'Plan Rows': 10000, + }, + ], + }, + }, + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'postgresql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + const lines = explain.explainNarrativeLines.value + expect(lines).toContain('Index usage: yes, hit users_pkey.') + expect(lines).toContain('Estimated rows to scan/process: about 10000.') + expect(lines).toContain('Actual rows need EXPLAIN ANALYZE. Turn on Analyze to measure them.') + expect(lines).toContain('Main operators: Limit, Index Scan Backward (backward index walk).') + expect(explain.explainNarrative.value).not.toContain('Detailed interpretation is not available yet.') + expect(explain.explainDetailJson.value).toContain('"Index Name": "users_pkey"') + }) +}) diff --git a/frontend/src/__tests__/console-explain-postgres-lines.test.ts b/frontend/src/__tests__/console-explain-postgres-lines.test.ts new file mode 100644 index 0000000..8fe0e93 --- /dev/null +++ b/frontend/src/__tests__/console-explain-postgres-lines.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { computed, ref } from 'vue' + +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' +import { useConsoleExplain } from '@/views/console/composables/useConsoleExplain' + +describe('useConsoleExplain postgres line detail', () => { + afterEach(() => { + resetAppI18nForTest() + }) + + it('renders stage + index narrative instead of generic fallback', () => { + setAppLocale('en') + + const statement = ref('SELECT * FROM users ORDER BY id DESC LIMIT 50;') + const explainResult = ref({ + usesIndex: true, + indexes: ['users_pkey'], + stages: ['Limit', 'Index Scan Backward'], + detail: [ + 'Limit (cost=0.28..2.01 rows=50 width=92)', + ' -> Index Scan Backward using users_pkey on users (cost=0.28..345.27 rows=10000 width=92)', + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'postgresql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + expect(explain.explainNarrativeLines.value).toEqual([ + 'Execution stages: Limit -> Index Scan Backward.', + 'Indexes used: users_pkey.', + ]) + expect(explain.explainNarrative.value).not.toContain('Detailed interpretation is not available yet.') + }) + + it('does not claim no index when index names are present in mixed plans', () => { + setAppLocale('en') + + const statement = ref('SELECT * FROM users WHERE email = ? LIMIT 1;') + const explainResult = ref({ + usesIndex: false, + indexes: ['idx_users_email'], + stages: ['Seq Scan', 'Index Scan'], + detail: [ + 'Seq Scan on users (cost=0.00..12.50 rows=1 width=4)', + 'Index Scan using idx_users_email on users (cost=0.00..8.27 rows=1 width=4)', + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'postgresql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + expect(explain.explainNarrativeLines.value).toEqual([ + 'Execution stages: Seq Scan -> Index Scan.', + 'Indexes observed in plan: idx_users_email.', + ]) + expect(explain.explainNarrative.value).not.toContain('No index is used currently') + }) +}) diff --git a/frontend/src/__tests__/console-explain-postgres-readable.test.ts b/frontend/src/__tests__/console-explain-postgres-readable.test.ts new file mode 100644 index 0000000..acb9e23 --- /dev/null +++ b/frontend/src/__tests__/console-explain-postgres-readable.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { computed, ref } from 'vue' + +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' +import { useConsoleExplain } from '@/views/console/composables/useConsoleExplain' + +describe('useConsoleExplain postgres readable narrative', () => { + afterEach(() => { + resetAppI18nForTest() + }) + + it('shows estimated rows and asks for ANALYZE when actual rows are unavailable', () => { + setAppLocale('en') + + const statement = ref('SELECT * FROM users ORDER BY id DESC LIMIT 50;') + const explainResult = ref({ + usesIndex: true, + indexes: ['users_pkey'], + stages: ['Limit', 'Index Scan Backward'], + detail: [ + { + Plan: { + 'Node Type': 'Limit', + 'Plan Rows': 50, + Plans: [ + { + 'Node Type': 'Index Scan', + 'Scan Direction': 'Backward', + 'Index Name': 'users_pkey', + 'Plan Rows': 10000, + 'Index Cond': '(id IS NOT NULL)', + }, + ], + }, + }, + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'postgresql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + const lines = explain.explainNarrativeLines.value + expect(lines).toContain('Index usage: yes, hit users_pkey.') + expect(lines).toContain('Estimated rows to scan/process: about 10000.') + expect(lines).toContain('Actual rows need EXPLAIN ANALYZE. Turn on Analyze to measure them.') + expect(lines).toContain('Main operators: Limit, Index Scan Backward (backward index walk).') + expect(lines).toContain('Index condition: (id IS NOT NULL).') + }) + + it('explains seq scan impact and actual rows from ANALYZE', () => { + setAppLocale('en') + + const statement = ref('SELECT * FROM users u JOIN orders o ON o.user_id = u.id WHERE u.status = \'active\';') + const explainResult = ref({ + usesIndex: false, + indexes: ['idx_orders_user_id'], + stages: ['Nested Loop', 'Index Scan', 'Seq Scan'], + detail: [ + { + Plan: { + 'Node Type': 'Nested Loop', + 'Plan Rows': 5000, + 'Actual Rows': 4200, + 'Actual Loops': 1, + Plans: [ + { + 'Node Type': 'Index Scan', + 'Relation Name': 'orders', + 'Index Name': 'idx_orders_user_id', + 'Plan Rows': 5000, + 'Actual Rows': 4200, + 'Actual Loops': 1, + 'Index Cond': '(o.user_id = u.id)', + }, + { + 'Node Type': 'Seq Scan', + 'Relation Name': 'users', + 'Plan Rows': 1000, + 'Actual Rows': 800, + 'Actual Loops': 300, + Filter: "(u.status = 'active')", + 'Rows Removed by Filter': 120000, + }, + ], + }, + }, + ], + } as any) + + const explain = useConsoleExplain({ + store: { current: { type: 'postgresql' } }, + statement, + explainResult, + isSQL: computed(() => true), + isMongo: computed(() => false), + }) + + const lines = explain.explainNarrativeLines.value + expect(lines).toContain('Index usage: partial. Indexes idx_orders_user_id appear in plan, but some steps still do full scans.') + expect(lines).toContain('Estimated rows to scan/process: about 5000.') + expect(lines).toContain('Actual rows processed (ANALYZE): about 240000.') + expect(lines).toContain('Seq Scan on users: this step scans the whole table.') + expect(lines).toContain('Index condition: (o.user_id = u.id).') + expect(lines).toContain("Filter condition: (u.status = 'active').") + expect(lines).toContain('Rows removed by filter: about 120000.') + }) +}) diff --git a/frontend/src/__tests__/console-explain-styling.test.ts b/frontend/src/__tests__/console-explain-styling.test.ts new file mode 100644 index 0000000..e34a6d5 --- /dev/null +++ b/frontend/src/__tests__/console-explain-styling.test.ts @@ -0,0 +1,128 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView explain styling', () => { + let pinia: ReturnType + const originalDocumentLang = typeof document !== 'undefined' ? document.documentElement.lang : '' + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + }) + + afterEach(() => { + if (typeof document !== 'undefined') { + document.documentElement.lang = originalDocumentLang + } + resetAppI18nForTest() + vi.restoreAllMocks() + }) + + const mountConsole = async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + return wrapper + } + + const runExplain = async (wrapper: ReturnType) => { + const statementInput = wrapper.find('#statement-input') + if (statementInput.exists()) { + await statementInput.setValue('SELECT * FROM users') + } else { + const monacoEditor = wrapper.findComponent({ name: 'ConsoleMonacoEditor' }) + expect(monacoEditor.exists()).toBe(true) + monacoEditor.vm.$emit('update:modelValue', 'SELECT * FROM users') + await flushPromises() + } + const explainLabel = tApp('console.statement.explain') + const explainButton = wrapper.findAll('button').find((btn) => btn.text() === explainLabel) + expect(explainButton).toBeTruthy() + await explainButton!.trigger('click') + await flushPromises() + } + + it('marks explain readable summary as success when index is used', async () => { + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + indexes: ['PRIMARY'], + stages: ['INDEX LOOKUP'], + detail: [], + }) + + const wrapper = await mountConsole() + await runExplain(wrapper) + + const narrative = wrapper.find('.explain-readable') + expect(narrative.exists()).toBe(true) + expect(narrative.classes()).toContain('success') + }) + + it('marks explain readable summary as danger when index is not used', async () => { + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: false, + indexes: [], + stages: ['FULL TABLE SCAN'], + detail: [], + }) + + const wrapper = await mountConsole() + await runExplain(wrapper) + + const narrative = wrapper.find('.explain-readable') + expect(narrative.exists()).toBe(true) + expect(narrative.classes()).toContain('danger') + }) + + it('renders explain title in Chinese when app locale is zh', async () => { + setAppLocale('zh') + if (typeof document !== 'undefined') { + document.documentElement.lang = 'zh-CN' + } + + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + indexes: ['PRIMARY'], + stages: ['INDEX LOOKUP'], + detail: [], + }) + + const wrapper = await mountConsole() + await runExplain(wrapper) + + expect(wrapper.find('.explain-card-head h5').text()).toBe('执行计划') + }) +}) diff --git a/frontend/src/__tests__/console-history-apply.test.ts b/frontend/src/__tests__/console-history-apply.test.ts new file mode 100644 index 0000000..45b0ba8 --- /dev/null +++ b/frontend/src/__tests__/console-history-apply.test.ts @@ -0,0 +1,62 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds1' }, query: { historyId: 'h1' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console history apply', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('applies history entry without auto-run', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + const getSpy = vi.spyOn(api, 'getHistory').mockResolvedValue({ + id: 'h1', + statement: 'SELECT * FROM orders', + executedAt: '2024-01-01T00:00:00Z', + datasourceId: 'ds1', + datasourceName: 'Primary', + datasourceType: 'mysql', + database: 'main', + targets: ['orders'], + tags: [], + }) + const execSpy = vi.spyOn(api, 'executeStatement') + + const store = useAppStore() + store.datasources = [ + { id: 'ds1', name: 'Primary', type: 'mysql', host: '', port: 0 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(getSpy).toHaveBeenCalledWith('h1') + expect(execSpy).not.toHaveBeenCalled() + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT * FROM orders') + expect(wrapper.text()).toContain('Current target') + expect(wrapper.text()).toContain('orders') + }) +}) diff --git a/frontend/src/__tests__/console-history-list.test.ts b/frontend/src/__tests__/console-history-list.test.ts new file mode 100644 index 0000000..edfbbc4 --- /dev/null +++ b/frontend/src/__tests__/console-history-list.test.ts @@ -0,0 +1,51 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console history list', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not render inline history panel in console anymore', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([ + { id: '1', statement: 'A', executedAt: '', datasourceId: 'ds_mysql', datasourceName: 'DS', datasourceType: 'mysql', database: '', targets: ['t'], tags: [] }, + { id: '2', statement: 'B', executedAt: '', datasourceId: 'ds_mysql', datasourceName: 'DS', datasourceType: 'mysql', database: '', targets: ['t'], tags: [] }, + { id: '3', statement: 'C', executedAt: '', datasourceId: 'ds_mysql', datasourceName: 'DS', datasourceType: 'mysql', database: '', targets: ['t'], tags: [] }, + { id: '4', statement: 'D', executedAt: '', datasourceId: 'ds_mysql', datasourceName: 'DS', datasourceType: 'mysql', database: '', targets: ['t'], tags: [] }, + ]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'DS', type: 'mysql', host: '', port: 0 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('.history').exists()).toBe(false) + expect(wrapper.findAll('.history-item').length).toBe(0) + }) +}) diff --git a/frontend/src/__tests__/console-history-recording-parity.test.ts b/frontend/src/__tests__/console-history-recording-parity.test.ts new file mode 100644 index 0000000..f395896 --- /dev/null +++ b/frontend/src/__tests__/console-history-recording-parity.test.ts @@ -0,0 +1,56 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console history recording in sql-editor parity mode', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('records history when executing from parity toolbar', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue(['users']) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ columns: ['ok'], rows: [[1]] } as any) + const appendSpy = vi.spyOn(api, 'appendHistory').mockResolvedValue({} as any) + + const store = useAppStore() + store.datasources = [{ id: 'ds_mysql', name: 'Primary', type: 'mysql', host: '', port: 3306 } as any] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT 1') + await wrapper.find('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(appendSpy).toHaveBeenCalled() + expect(appendSpy).toHaveBeenCalledWith( + expect.objectContaining({ + datasourceId: 'ds_mysql', + statement: 'SELECT 1', + }), + ) + }) +}) diff --git a/frontend/src/__tests__/console-history-redis-db.test.ts b/frontend/src/__tests__/console-history-redis-db.test.ts new file mode 100644 index 0000000..d698e50 --- /dev/null +++ b/frontend/src/__tests__/console-history-redis-db.test.ts @@ -0,0 +1,52 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console history redis database', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('omits database when listing history for redis', async () => { + const listSpy = vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: 0, done: true }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379, database: '0' } as any, + ] + + mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const call = listSpy.mock.calls[0]?.[0] + expect(call).toEqual( + expect.objectContaining({ + datasourceId: 'ds_redis', + limit: 2, + }), + ) + expect(call).not.toHaveProperty('database') + }) +}) diff --git a/frontend/src/__tests__/console-i18n-messages.test.ts b/frontend/src/__tests__/console-i18n-messages.test.ts new file mode 100644 index 0000000..7eb4e07 --- /dev/null +++ b/frontend/src/__tests__/console-i18n-messages.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { formatAppList, getAppLocale, resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const originalDocumentLang = typeof document !== 'undefined' ? document.documentElement.lang : '' + +afterEach(() => { + resetAppI18nForTest() + if (typeof document !== 'undefined') { + document.documentElement.lang = originalDocumentLang + } +}) + +describe('console i18n messages', () => { + it('uses app locale first for console wording', () => { + setAppLocale('zh') + if (typeof document !== 'undefined') { + document.documentElement.lang = 'en' + } + + expect(getAppLocale()).toBe('zh') + expect(tApp('explain.title')).toBe('执行计划') + }) + + it('falls back to en wording for keys not translated in non-en/zh app locales', () => { + setAppLocale('de') + if (typeof document !== 'undefined') { + document.documentElement.lang = 'zh' + } + + expect(getAppLocale()).toBe('de') + expect(tApp('explain.title')).toBe('Explain Plan') + }) + + it('supports forced locale override for deterministic tests', () => { + setAppLocale('en') + expect(tApp('danger.runAnyway')).toBe('Run anyway') + + setAppLocale('zh') + expect(tApp('danger.runAnyway')).toBe('仍然执行') + }) + + it('interpolates parameters from unified message map', () => { + setAppLocale('en') + expect(tApp('danger.detected', { value: 'Index used' })).toBe('Detected: Index used') + + setAppLocale('zh') + expect(tApp('danger.detected', { value: '命中索引' })).toBe('检测结果:命中索引') + }) + + it('formats localized list separators for explain wording', () => { + setAppLocale('en') + expect(formatAppList(['a', 'b'])).toBe('a, b') + expect(formatAppList(['x', 'y'], 'common.metricSeparator')).toBe('x, y') + + setAppLocale('zh') + expect(formatAppList(['甲', '乙'])).toBe('甲、乙') + expect(formatAppList(['键扫描 1', '文档扫描 2'], 'common.metricSeparator')).toBe('键扫描 1,文档扫描 2') + }) + + it('uses fully localized explain status text in zh', () => { + setAppLocale('zh') + expect(tApp('status.explainUsesIndex')).toBe('执行计划 | 命中索引') + expect(tApp('status.explainNoIndex')).toBe('执行计划 | 未命中索引') + }) + + it('includes plain-language sql explain wording in both locales', () => { + setAppLocale('en') + expect(tApp('explain.sql.rows.actual.needAnalyze')) + .toBe('Actual rows need EXPLAIN ANALYZE. Turn on Analyze to measure them.') + expect(tApp('explain.sql.mysql.accessType.RANGE')) + .toBe('RANGE (range scan, usually on an index interval)') + + setAppLocale('zh') + expect(tApp('explain.sql.rows.actual.needAnalyze')) + .toBe('实际行数需要 EXPLAIN ANALYZE;开启 Analyze 后才能测出来。') + expect(tApp('explain.sql.mysql.accessType.RANGE')) + .toBe('RANGE(范围扫描,通常会用到索引区间)') + }) +}) diff --git a/frontend/src/__tests__/console-monaco-completion-provider.test.ts b/frontend/src/__tests__/console-monaco-completion-provider.test.ts new file mode 100644 index 0000000..244be93 --- /dev/null +++ b/frontend/src/__tests__/console-monaco-completion-provider.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { buildMonacoCompletionPayload } from '@/components/consoleMonacoCompletion' + +describe('console monaco completion provider payload', () => { + it('returns SQL keyword suggestions for select prefix', () => { + const payload = buildMonacoCompletionPayload({ + statement: 'sel', + cursorOffset: 3, + datasourceType: 'mysql', + entities: ['table_0001'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'table_0001', + }) + + const labels = payload.items.map((item) => item.label) + expect(labels).toContain('SELECT') + }) + + it('returns mongo db method suggestions for db dot context', () => { + const payload = buildMonacoCompletionPayload({ + statement: 'db.', + cursorOffset: 3, + datasourceType: 'mongodb', + entities: ['events'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'events', + }) + + const labels = payload.items.map((item) => item.label) + expect(labels).toContain('createCollection()') + }) + + it('returns bracket mongo collection completion with valid accessor insertion text', () => { + const payload = buildMonacoCompletionPayload({ + statement: 'db["us', + cursorOffset: 6, + datasourceType: 'mongodb', + entities: ['users'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'users', + }) + + const usersItem = payload.items.find((item) => item.label === 'users') + expect(usersItem?.insertText).toBe('users"].') + }) + + it('replaces trailing auto-closed bracket tokens in mongo bracket context', () => { + const statement = 'db["us"]' + const payload = buildMonacoCompletionPayload({ + statement, + cursorOffset: 6, + datasourceType: 'mongodb', + entities: ['users'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'users', + }) + + const usersItem = payload.items.find((item) => item.label === 'users') + expect(usersItem?.insertText).toBe('users"].') + expect(payload.insertStart).toBe(4) + expect(payload.insertEnd).toBe(8) + + const replaced = statement.slice(0, payload.insertStart) + usersItem!.insertText + statement.slice(payload.insertEnd) + expect(replaced).toBe('db["users"].') + }) + + it('avoids duplicate dots when replacing bracket collection before chained method', () => { + const statement = 'db["us"].find({})' + const payload = buildMonacoCompletionPayload({ + statement, + cursorOffset: 6, + datasourceType: 'mongodb', + entities: ['users'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'users', + }) + + const usersItem = payload.items.find((item) => item.label === 'users') + expect(usersItem?.insertText).toBe('users"].') + + const replaced = statement.slice(0, payload.insertStart) + usersItem!.insertText + statement.slice(payload.insertEnd) + expect(replaced).toBe('db["users"].find({})') + }) + + it('returns elastic index suggestions for path context', () => { + const payload = buildMonacoCompletionPayload({ + statement: 'GET /', + cursorOffset: 5, + datasourceType: 'elasticsearch', + entities: ['futrixdata-demo-1'], + entityDetail: null, + entityDetailsMap: {}, + activeEntity: 'futrixdata-demo-1', + }) + + const labels = payload.items.map((item) => item.label) + expect(labels).toContain('futrixdata-demo-1') + }) +}) diff --git a/frontend/src/__tests__/console-monaco-context-menu.test.ts b/frontend/src/__tests__/console-monaco-context-menu.test.ts new file mode 100644 index 0000000..1639fc3 --- /dev/null +++ b/frontend/src/__tests__/console-monaco-context-menu.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { resolveContextOffset, resolveContextSelection } from '@/components/consoleMonacoContextMenu' + +describe('resolveContextSelection', () => { + it('returns current selection when selection is non-empty', () => { + const result = resolveContextSelection({ + textLength: 120, + currentSelection: { start: 8, end: 26 }, + contextOffset: 14, + lastNonEmptySelection: { start: 3, end: 5 }, + }) + expect(result).toEqual({ start: 8, end: 26 }) + }) + + it('falls back to previous non-empty selection when right click happens inside it', () => { + const result = resolveContextSelection({ + textLength: 120, + currentSelection: { start: 32, end: 32 }, + contextOffset: 18, + lastNonEmptySelection: { start: 12, end: 28 }, + allowLastSelectionFallback: true, + }) + expect(result).toEqual({ start: 12, end: 28 }) + }) + + it('does not revive stale previous selection after caret has collapsed', () => { + const result = resolveContextSelection({ + textLength: 120, + currentSelection: { start: 32, end: 32 }, + contextOffset: 18, + lastNonEmptySelection: { start: 12, end: 28 }, + }) + expect(result).toEqual({ start: 18, end: 18 }) + }) + + it('keeps collapsed selection when context click is outside previous selection', () => { + const result = resolveContextSelection({ + textLength: 120, + currentSelection: { start: 60, end: 60 }, + contextOffset: 60, + lastNonEmptySelection: { start: 12, end: 28 }, + allowLastSelectionFallback: true, + }) + expect(result).toEqual({ start: 12, end: 28 }) + }) +}) + +describe('resolveContextOffset', () => { + it('prefers explicit context menu offset when available', () => { + const result = resolveContextOffset({ + textLength: 120, + contextOffset: 52, + mouseDownOffset: 37, + selectionOffset: 10, + }) + expect(result).toBe(52) + }) + + it('falls back to mouse down offset when context target offset is unavailable', () => { + const result = resolveContextOffset({ + textLength: 120, + contextOffset: null, + mouseDownOffset: 37, + selectionOffset: 10, + }) + expect(result).toBe(37) + }) + + it('falls back to selection offset when no pointer offsets are available', () => { + const result = resolveContextOffset({ + textLength: 120, + contextOffset: undefined, + mouseDownOffset: undefined, + selectionOffset: 10, + }) + expect(result).toBe(10) + }) +}) diff --git a/frontend/src/__tests__/console-monaco-editor-css.test.ts b/frontend/src/__tests__/console-monaco-editor-css.test.ts new file mode 100644 index 0000000..5819e79 --- /dev/null +++ b/frontend/src/__tests__/console-monaco-editor-css.test.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +const componentPath = path.resolve(__dirname, '..', 'components', 'ConsoleMonacoEditor.vue') +const source = fs.readFileSync(componentPath, 'utf8') + +describe('console monaco editor css regressions', () => { + it('keeps monaco editor containers shrink-safe inside narrow sql parity layouts', () => { + const root = source.match(/\.console-monaco-editor\s*\{[\s\S]*?\}/)?.[0] ?? '' + const viewport = source.match(/\.console-monaco-editor__viewport\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(root).toMatch(/min-width:\s*0/i) + expect(root).toMatch(/overflow:\s*hidden/i) + expect(viewport).toMatch(/min-width:\s*0/i) + expect(viewport).toMatch(/overflow:\s*hidden/i) + }) +}) diff --git a/frontend/src/__tests__/console-monaco-environment.test.ts b/frontend/src/__tests__/console-monaco-environment.test.ts new file mode 100644 index 0000000..cbec64e --- /dev/null +++ b/frontend/src/__tests__/console-monaco-environment.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { ensureMonacoEnvironment, monacoWorkerKindForLabel } from '@/components/consoleMonacoEnvironment' + +type MonacoEnvironmentTestHost = typeof globalThis & { + MonacoEnvironment?: { + getWorker?: unknown + customFlag?: boolean + } + __futrixMonacoEnvironmentReady?: boolean +} + +const host = globalThis as MonacoEnvironmentTestHost + +describe('console Monaco environment', () => { + beforeEach(() => { + delete host.MonacoEnvironment + delete host.__futrixMonacoEnvironmentReady + }) + + it('maps Monaco language labels to Vite worker bundles', () => { + expect(monacoWorkerKindForLabel('json')).toBe('json') + expect(monacoWorkerKindForLabel('scss')).toBe('css') + expect(monacoWorkerKindForLabel('handlebars')).toBe('html') + expect(monacoWorkerKindForLabel('javascript')).toBe('typescript') + expect(monacoWorkerKindForLabel('sql')).toBe('editor') + }) + + it('installs a worker factory before Monaco is loaded', () => { + host.MonacoEnvironment = { customFlag: true } + + ensureMonacoEnvironment() + + expect(host.MonacoEnvironment?.customFlag).toBe(true) + expect(typeof host.MonacoEnvironment?.getWorker).toBe('function') + expect(host.__futrixMonacoEnvironmentReady).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/console-mongo-auto-pagination.test.ts b/frontend/src/__tests__/console-mongo-auto-pagination.test.ts new file mode 100644 index 0000000..26e0f99 --- /dev/null +++ b/frontend/src/__tests__/console-mongo-auto-pagination.test.ts @@ -0,0 +1,162 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mongo' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView Mongo auto pagination', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('paginates mongo find results using tokens', async () => { + const rows = Array.from({ length: 201 }, (_, idx) => ({ _id: 201 - idx })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + rows, + rowCount: rows.length, + nextToken: 'next-token', + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + rows: [], + rowCount: 0, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mongo', + name: 'Mongo', + type: 'mongodb', + host: 'localhost', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + expect(wrapper.find('#result').classes()).toContain('result--mongo') + + await getConsoleStatementInput(wrapper).setValue('db.users.find({})') + const executeButton = wrapper.findAll('button').find((btn) => btn.text() === 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + const statement = executeSpy.mock.calls[0]?.[1] as string + expect(statement).toBe('db.users.find({})') + expect(executeSpy.mock.calls[0]?.[3]).toBe('') + expect(executeSpy.mock.calls[0]?.[4]).toBe(200) + + const resultEl = wrapper.find('#result').element as HTMLElement + Object.defineProperty(resultEl, 'scrollTop', { value: 900, writable: true, configurable: true }) + Object.defineProperty(resultEl, 'clientHeight', { value: 200, configurable: true }) + Object.defineProperty(resultEl, 'scrollHeight', { value: 1000, configurable: true }) + await wrapper.find('#result').trigger('scroll') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + const nextStatement = executeSpy.mock.calls[1]?.[1] as string + expect(nextStatement).toBe('db.users.find({})') + expect(executeSpy.mock.calls[1]?.[3]).toBe('next-token') + expect(executeSpy.mock.calls[1]?.[4]).toBe(200) + }) + + it('paginates when mongo sort is not _id', async () => { + const rows = Array.from({ length: 201 }, (_, idx) => ({ _id: idx + 1, name: `name_${idx + 1}` })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + rows, + rowCount: rows.length, + nextToken: 'next-token', + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + rows: [], + rowCount: 0, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mongo', + name: 'Mongo', + type: 'mongodb', + host: 'localhost', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('db.users.find({}).sort({ name: 1 })') + const executeButton = wrapper.findAll('button').find((btn) => btn.text() === 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + const statement = executeSpy.mock.calls[0]?.[1] as string + expect(statement).toBe('db.users.find({}).sort({ name: 1 })') + expect(executeSpy.mock.calls[0]?.[3]).toBe('') + expect(executeSpy.mock.calls[0]?.[4]).toBe(200) + + const resultEl = wrapper.find('#result').element as HTMLElement + Object.defineProperty(resultEl, 'scrollTop', { value: 900, writable: true, configurable: true }) + Object.defineProperty(resultEl, 'clientHeight', { value: 200, configurable: true }) + Object.defineProperty(resultEl, 'scrollHeight', { value: 1000, configurable: true }) + await wrapper.find('#result').trigger('scroll') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + const nextStatement = executeSpy.mock.calls[1]?.[1] as string + expect(nextStatement).toBe('db.users.find({}).sort({ name: 1 })') + expect(executeSpy.mock.calls[1]?.[3]).toBe('next-token') + expect(executeSpy.mock.calls[1]?.[4]).toBe(200) + }) +}) diff --git a/frontend/src/__tests__/console-mongo-explain-confirm.test.ts b/frontend/src/__tests__/console-mongo-explain-confirm.test.ts new file mode 100644 index 0000000..8789690 --- /dev/null +++ b/frontend/src/__tests__/console-mongo-explain-confirm.test.ts @@ -0,0 +1,63 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mongo' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Mongo explain confirm', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows confirm when riskengine blocks', async () => { + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'explainStatement').mockResolvedValue({ usesIndex: false, detail: {} } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], rows: [], rowCount: 0, elapsedMs: 0, + riskInfo: { action: 'warn', level: 'medium', reasons: ['full scan without index'] }, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mongo', name: 'Mongo', type: 'mongodb', host: '', port: 0, database: 'testdb' } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = wrapper.find('#statement-input') + if (statementInput.exists()) { + await statementInput.setValue('db.users.find({})') + } else { + const fallbackInput = wrapper.find('textarea.console-monaco-editor__fallback') + expect(fallbackInput.exists()).toBe(true) + await fallbackInput.setValue('db.users.find({})') + } + const executeButton = wrapper.findAll('button').find((btn) => btn.text() === 'Execute') + expect(executeButton).toBeTruthy() + await executeButton!.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="risk-danger-dialog"]').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/console-mongo-helpers-wrap-css.test.ts b/frontend/src/__tests__/console-mongo-helpers-wrap-css.test.ts new file mode 100644 index 0000000..dddf799 --- /dev/null +++ b/frontend/src/__tests__/console-mongo-helpers-wrap-css.test.ts @@ -0,0 +1,21 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadMongoHelperCss = () => { + const filePath = path.resolve(__dirname, '..', 'styles', 'console', 'templates-redis-mongo-menu.css') + return readCssWithImports(filePath) +} + +describe('console mongo helpers CSS', () => { + it('wraps helper buttons inside suggestion actions', () => { + const css = loadMongoHelperCss() + const block = css.match(/\.suggestion-actions\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('flex-wrap: wrap') + expect(block).toContain('width: 100%') + expect(block).toContain('min-width: 0') + }) +}) diff --git a/frontend/src/__tests__/console-pagination-ui.test.ts b/frontend/src/__tests__/console-pagination-ui.test.ts new file mode 100644 index 0000000..f32ed13 --- /dev/null +++ b/frontend/src/__tests__/console-pagination-ui.test.ts @@ -0,0 +1,190 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { tApp } from '@/modules/i18n/appI18n' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_console' }, query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +const makeDatasource = (type: 'mysql' | 'mongodb') => ({ + id: 'ds_console', + name: 'Console', + type, + host: 'localhost', + port: type === 'mongodb' ? 27017 : 3306, + username: '', + password: '', + database: type === 'mongodb' ? 'admin' : '', + authSource: '', + options: {}, +}) + +const clickExecute = async (wrapper: ReturnType) => { + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + await executeButton.trigger('click') + await flushPromises() +} + +describe('ConsoleView pagination UI polish', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps SQL parity pagination in footer and hides legacy toolbar copy controls', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })), + rowCount: 200, + elapsedMs: 12, + nextToken: 'token-1', + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users LIMIT 10000') + await clickExecute(wrapper) + + expect(wrapper.find('.result-toolbar').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-page-copy"]').exists()).toBe(false) + expect(wrapper.find('.result-footer-sql-editor .pager').exists()).toBe(true) + }) + + it('keeps Mongo parity copy actions hidden and renders compact result list', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [{ _id: 1, name: 'Doc' }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mongodb')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('db.users.find({})') + await clickExecute(wrapper) + + expect(wrapper.find('[data-testid="mongo-result-copy"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="mongo-row-copy"]').exists()).toBe(false) + }) + + it('hides SQL row copy controls in parity mode', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + await clickExecute(wrapper) + + expect(wrapper.find('[data-testid="result-row-copy"]').exists()).toBe(false) + }) + + it('advances parity footer pager after loading the next SQL page', async () => { + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows: Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })), + rowCount: 200, + elapsedMs: 12, + nextToken: 't1', + }) + .mockResolvedValueOnce({ + columns: ['id'], + rows: Array.from({ length: 200 }, (_, idx) => ({ id: idx + 201 })), + rowCount: 400, + elapsedMs: 12, + nextToken: '', + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users LIMIT 10000') + await clickExecute(wrapper) + + const nextButton = wrapper.find(`button[aria-label="${tApp('console.results.nextPageAria')}"]`) + expect(nextButton.exists()).toBe(true) + + await nextButton.trigger('click') + await flushPromises() + + expect(wrapper.find('.result-footer-sql-editor .pager button.active').text()).toBe('2') + expect(executeSpy).toHaveBeenCalledTimes(2) + }) + + it('does not render horizontal scroll controls in SQL results', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'c8', 'c9'], + rows: [{ c1: 1 }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + await clickExecute(wrapper) + + expect(wrapper.find('[aria-label="Scroll result table left"]').exists()).toBe(false) + expect(wrapper.find('[aria-label="Scroll result table right"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-paging-source.test.ts b/frontend/src/__tests__/console-paging-source.test.ts new file mode 100644 index 0000000..da0ad72 --- /dev/null +++ b/frontend/src/__tests__/console-paging-source.test.ts @@ -0,0 +1,337 @@ +import { computed, ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import type { QueryResult } from '@/types' +import { useSqlPaging } from '@/views/console/composables/useSqlPaging' +import { useMongoPaging } from '@/views/console/composables/useMongoPaging' +import { useDynamoPaging } from '@/views/console/composables/useDynamoPaging' + +const rows = (count: number, start = 1) => + Array.from({ length: count }, (_, idx) => ({ id: start + idx })) + +describe('Console paging source compatibility', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('continues SQL paging when editor text contains multiple statements', async () => { + const store = useAppStore() + store.current = { + id: 'ds_sql', + type: 'mysql', + name: 'MySQL', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any + + const statement = ref('SELECT 1;\nSELECT * FROM users ORDER BY id;') + const result = ref({ + columns: ['id'], + rows: rows(200), + rowCount: 200, + hasMore: true, + nextToken: 'token-1', + prevToken: '', + elapsedMs: 11, + }) + const resultMeta = ref('') + const statusMessage = ref('') + const statusType = ref('') + const explainResult = ref(null) + + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: rows(1, 201), + rowCount: 1, + hasMore: true, + nextToken: 'token-2', + prevToken: 'token-0', + elapsedMs: 9, + } as any) + + const paging = useSqlPaging({ + statement, + result, + resultRows: computed(() => result.value?.rows || []), + resultMeta, + statusMessage, + statusType, + explainResult, + isSQL: computed(() => true), + isD1: computed(() => false), + d1ExecutionMode: ref<'dev' | 'remote'>('remote'), + renderTable: computed(() => true), + resultShell: ref(null), + virtualTableRef: ref(null), + markActive: vi.fn(), + }) + + paging.sqlPagingActive.value = true + paging.sqlHasNext.value = true + paging.sqlPagingSource.value = 'SELECT * FROM users ORDER BY id' + paging.sqlPagingNextToken.value = 'token-1' + + await paging.loadNextSqlPage() + + expect(executeSpy).toHaveBeenCalledWith( + 'ds_sql', + 'SELECT * FROM users ORDER BY id', + '', + 'token-1', + 200, + '', + true, + ) + expect(result.value?.rows?.length).toBe(201) + }) + + it('appends ordered SQL row values when loading the next page', async () => { + const store = useAppStore() + store.current = { + id: 'ds_sql', + type: 'mysql', + name: 'MySQL', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + } as any + + const statement = ref('SELECT u.id, o.id FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.id;') + const result = ref({ + columns: ['id', 'id__2'], + rows: rows(200).map((row, idx) => ({ id: row.id, id__2: idx + 1001 })), + rowValues: rows(200).map((row, idx) => [row.id, idx + 1001]), + rowCount: 200, + hasMore: true, + nextToken: 'token-1', + prevToken: '', + elapsedMs: 11, + }) + const resultMeta = ref('') + const statusMessage = ref('') + const statusType = ref('') + const explainResult = ref(null) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'id__2'], + rows: [{ id: 201, id__2: 1201 }], + rowValues: [[201, 1201]], + rowCount: 1, + hasMore: false, + nextToken: '', + prevToken: 'token-0', + elapsedMs: 9, + } as any) + + const paging = useSqlPaging({ + statement, + result, + resultRows: computed(() => result.value?.rows || []), + resultMeta, + statusMessage, + statusType, + explainResult, + isSQL: computed(() => true), + isD1: computed(() => false), + d1ExecutionMode: ref<'dev' | 'remote'>('remote'), + renderTable: computed(() => true), + resultShell: ref(null), + virtualTableRef: ref(null), + markActive: vi.fn(), + }) + + paging.sqlPagingActive.value = true + paging.sqlHasNext.value = true + paging.sqlPagingSource.value = 'SELECT u.id, o.id FROM users u JOIN orders o ON u.id = o.user_id ORDER BY o.id' + paging.sqlPagingNextToken.value = 'token-1' + + await paging.loadNextSqlPage() + + expect(result.value?.rows?.length).toBe(201) + expect(result.value?.rowValues?.length).toBe(201) + expect(result.value?.rowValues?.[200]).toEqual([201, 1201]) + }) + + it('continues Mongo paging when editor text contains multiple statements', async () => { + const store = useAppStore() + store.current = { + id: 'ds_mongo', + type: 'mongodb', + name: 'MongoDB', + host: 'localhost', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: '', + options: {}, + } as any + store.mongoDatabase = 'admin' + + const statement = ref('db.audit.find({});\ndb.users.find({})') + const result = ref({ + columns: [], + rows: rows(200), + rowCount: 200, + hasMore: true, + nextToken: 'm-token-1', + prevToken: '', + elapsedMs: 12, + }) + const resultMeta = ref('') + const statusMessage = ref('') + const statusType = ref('') + const explainResult = ref(null) + + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: rows(1, 201), + rowCount: 1, + hasMore: true, + nextToken: 'm-token-2', + prevToken: 'm-token-0', + elapsedMs: 7, + } as any) + + const paging = useMongoPaging({ + statement, + result, + resultMeta, + statusMessage, + statusType, + explainResult, + isMongo: computed(() => true), + markActive: vi.fn(), + }) + + paging.mongoPagingActive.value = true + paging.mongoPagingHasNext.value = true + paging.mongoPagingSource.value = 'db.users.find({})' + paging.mongoPagingNextToken.value = 'm-token-1' + + await paging.loadNextMongoPage() + + expect(executeSpy).toHaveBeenCalledWith( + 'ds_mongo', + 'db.users.find({})', + 'admin', + 'm-token-1', + 200, + '', + true, + ) + expect(result.value?.rows?.length).toBe(201) + }) + + it('continues Dynamo paging when editor text contains multiple statements', async () => { + const store = useAppStore() + store.current = { + id: 'ds_dynamo', + type: 'dynamodb', + name: 'DynamoDB', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { region: 'us-east-1' }, + } as any + + const statement = ref('SELECT 1;\nSELECT * FROM "users"') + const result = ref({ + columns: ['id'], + rows: rows(100), + rowCount: 100, + hasMore: true, + nextToken: 'd-token-1', + prevToken: '', + elapsedMs: 12, + }) + const resultMeta = ref('') + const statusMessage = ref('') + const statusType = ref('') + const explainResult = ref(null) + + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: rows(1, 101), + rowCount: 1, + hasMore: true, + nextToken: 'd-token-2', + prevToken: 'd-token-0', + elapsedMs: 8, + detail: { + effectivePageSize: 100, + maxPages: 20, + pagesFetched: 1, + stopReason: 'page_limit', + requestedLimits: { + maxReturnedRows: 100, + maxPages: 50, + maxEvaluatedItems: 10000, + }, + effectiveLimits: { + maxReturnedRows: 100, + maxPages: 20, + maxEvaluatedItems: 5000, + }, + clampedLimits: { + maxPages: true, + maxEvaluatedItems: true, + }, + }, + } as any) + + const paging = useDynamoPaging({ + statement, + result, + resultMeta, + statusMessage, + statusType, + explainResult, + isDynamo: computed(() => true), + markActive: vi.fn(), + }) + + paging.dynamoPagingActive.value = true + paging.dynamoPagingHasNext.value = true + paging.dynamoPagingSource.value = 'SELECT * FROM "users"' + paging.dynamoPagingNextToken.value = 'd-token-1' + + await paging.loadNextDynamoPage() + + expect(executeSpy).toHaveBeenCalledWith( + 'ds_dynamo', + 'SELECT * FROM "users"', + '', + 'd-token-1', + 100, + '', + true, + { + maxReturnedRows: 100, + maxPages: 5, + maxEvaluatedItems: 500, + }, + ) + expect(result.value?.rows?.length).toBe(101) + expect(resultMeta.value).toContain('Clamped: page limit, evaluated item limit') + }) +}) diff --git a/frontend/src/__tests__/console-redis-command-result-in-inspector.test.ts b/frontend/src/__tests__/console-redis-command-result-in-inspector.test.ts new file mode 100644 index 0000000..2a54ce9 --- /dev/null +++ b/frontend/src/__tests__/console-redis-command-result-in-inspector.test.ts @@ -0,0 +1,72 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView redis CLI output placement', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'appendHistory').mockResolvedValue({} as any) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [{ result: 'OK' }], + rowCount: 1, + elapsedMs: 12, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders Redis execution output in the CLI log (not the bottom results panel)', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const cliInput = wrapper.get('[data-testid="redis-cli-input"]') + await cliInput.setValue('GET foo') + await cliInput.trigger('keydown.enter') + await flushPromises() + await flushPromises() + + const cliLines = wrapper.get('#cli-lines') + expect(cliLines.text()).toContain('GET foo') + expect(cliLines.text()).toContain('OK') + + expect(wrapper.find('#result').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-redis-dangerous-command.test.ts b/frontend/src/__tests__/console-redis-dangerous-command.test.ts new file mode 100644 index 0000000..a8395c7 --- /dev/null +++ b/frontend/src/__tests__/console-redis-dangerous-command.test.ts @@ -0,0 +1,76 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView redis dangerous command', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('requires confirmation before executing', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const executeSpy = vi.spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: [], rows: [], rowCount: 0, elapsedMs: 0, + riskInfo: { action: 'block', level: 'high', reasons: ['destructive command: FLUSHALL'] }, + } as any) + .mockResolvedValueOnce({ + columns: [], rows: [], rowCount: 0, elapsedMs: 1, + }) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const cliInput = wrapper.get('[data-testid="redis-cli-input"]') + await cliInput.setValue('FLUSHALL') + await cliInput.trigger('keydown.enter') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-testid="risk-danger-dialog"]').exists()).toBe(true) + + await wrapper.get('[data-testid="risk-danger-confirm"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy).toHaveBeenLastCalledWith('ds_redis', 'FLUSHALL', '', '', 0, '', true) + }) +}) diff --git a/frontend/src/__tests__/console-redis-template-bar.test.ts b/frontend/src/__tests__/console-redis-template-bar.test.ts new file mode 100644 index 0000000..6f4df50 --- /dev/null +++ b/frontend/src/__tests__/console-redis-template-bar.test.ts @@ -0,0 +1,59 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView redis template bar', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hides redis template inputs', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('#template-target').exists()).toBe(false) + expect(wrapper.find('#redis-value').exists()).toBe(false) + expect(wrapper.find('#redis-field').exists()).toBe(false) + expect(wrapper.find('#redis-start').exists()).toBe(false) + expect(wrapper.find('#redis-stop').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-result-copy.test.ts b/frontend/src/__tests__/console-result-copy.test.ts new file mode 100644 index 0000000..331725a --- /dev/null +++ b/frontend/src/__tests__/console-result-copy.test.ts @@ -0,0 +1,209 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +let routeId = 'ds_mysql' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: routeId } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView result copy', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hides SQL page and row copy controls in parity mode', async () => { + routeId = 'ds_mysql' + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const rows = [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ] + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows, + rowCount: rows.length, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + + await executeButton.trigger('click') + await flushPromises() + + const headers = wrapper.findAll('thead th') + expect(headers[0]?.text()).toBe('#') + expect(wrapper.find('[data-testid="result-page-copy"]').exists()).toBe(false) + expect(wrapper.findAll('[data-testid="result-row-copy"]')).toHaveLength(0) + expect(writeText).not.toHaveBeenCalled() + }) + + it('hides Mongo JSON copy actions in parity mode', async () => { + routeId = 'ds_mongo' + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const rows = [ + { _id: 1, name: 'Alpha' }, + { _id: 2, name: 'Beta' }, + ] + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows, + rowCount: rows.length, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mongo', + name: 'Mongo', + type: 'mongodb', + host: 'localhost', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('db.users.find({})') + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + + await executeButton.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="mongo-result-copy"]').exists()).toBe(false) + expect(wrapper.findAll('[data-testid="mongo-row-copy"]')).toHaveLength(0) + expect(writeText).not.toHaveBeenCalled() + }) + + it('copies Redis result output', async () => { + routeId = 'ds_redis' + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['sample_key'], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'copy me', + truncated: false, + }, + }) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [{ result: 'OK' }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const keyList = wrapper.get('#key-list') + const keyRow = keyList.findAll('button').find((btn) => btn.text().includes('sample_key')) + expect(keyRow).toBeTruthy() + await keyRow!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="value"]').trigger('click') + await flushPromises() + + await wrapper.get('#viewer-action-copy').trigger('click') + expect(writeText).toHaveBeenCalledWith('copy me') + }) + +}) diff --git a/frontend/src/__tests__/console-result-css.test.ts b/frontend/src/__tests__/console-result-css.test.ts new file mode 100644 index 0000000..964a2ae --- /dev/null +++ b/frontend/src/__tests__/console-result-css.test.ts @@ -0,0 +1,44 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('console result CSS', () => { + it('adds a tinted container for mongo result gaps', () => { + const css = loadStyleCss() + const block = css.match(/\.result--mongo[\s\S]*?\.mongo-result-list[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('background: color-mix(in oklab, var(--panel-soft) 88%, var(--primary) 12%)') + }) + + it('tightens sql result padding', () => { + const css = loadStyleCss() + const block = css.match(/\.result--sql\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('padding-top: 2px') + }) + + it('keeps redis preview content scrollable', () => { + const css = loadStyleCss() + const block = css.match(/\.redis-preview-body\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('min-height') + expect(block).toContain('max-height') + expect(block).toContain('overflow-y: auto') + expect(block).toContain('overflow-x: hidden') + }) + + it('wraps long redis preview values', () => { + const css = loadStyleCss() + const block = css.match(/\.redis-value\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('overflow-wrap') + expect(block).toContain('word-break') + }) +}) diff --git a/frontend/src/__tests__/console-result-export.test.ts b/frontend/src/__tests__/console-result-export.test.ts new file mode 100644 index 0000000..191b27a --- /dev/null +++ b/frontend/src/__tests__/console-result-export.test.ts @@ -0,0 +1,274 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +let routeId = 'ds_mysql' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: routeId } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const getStatementEditorInput = (wrapper: ReturnType) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} + +describe('ConsoleView result export', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + + Object.defineProperty(URL, 'createObjectURL', { + value: vi.fn(() => 'blob://result-export'), + configurable: true, + writable: true, + }) + Object.defineProperty(URL, 'revokeObjectURL', { + value: vi.fn(), + configurable: true, + writable: true, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('uses Wails backend export when runtime binding exists', async () => { + routeId = 'ds_mysql' + const exportSpy = vi.fn().mockResolvedValue('/tmp/mysql-result.csv') + vi.stubGlobal('go', { + main: { + App: { + ExportQueryResult: exportSpy, + }, + }, + }) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + rowCount: 2, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT * FROM users') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const exportButton = wrapper.get('[data-testid="result-filter-export"]') + await exportButton.trigger('click') + await flushPromises() + + expect(exportSpy).toHaveBeenCalledTimes(1) + }) + + it('hides export button when showing explain result', async () => { + routeId = 'ds_mysql' + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + detail: [{ key: 'PRIMARY' }], + stages: ['INDEX'], + indexes: ['PRIMARY'], + totalDocsExamined: 1, + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT * FROM users') + await wrapper.get('.editor-toolbar-sql-editor .explain-btn').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="result-filter-export"]').exists()).toBe(false) + }) + + it('exports duplicate SQL columns without dropping the second column', async () => { + routeId = 'ds_mysql' + const exportSpy = vi.fn().mockResolvedValue('/tmp/mysql-duplicate-result.csv') + vi.stubGlobal('go', { + main: { + App: { + ExportQueryResult: exportSpy, + }, + }, + }) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'id__2'], + rows: [ + { id: 1, id__2: 9 }, + ], + columnMeta: [ + { key: 'id', name: 'id', position: 0 }, + { key: 'id__2', name: 'id', position: 1 }, + ], + rowValues: [[1, 9]], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT u.id, o.id FROM users u JOIN orders o ON u.id = o.user_id') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + await wrapper.get('[data-testid="result-filter-export"]').trigger('click') + await flushPromises() + + expect(exportSpy).toHaveBeenCalledTimes(1) + const [, content] = exportSpy.mock.calls[0] || [] + expect(content).toContain('#,id,id') + expect(content).toContain('1,1,9') + }) + + it('falls back to row maps when later rows have no ordered SQL values', async () => { + routeId = 'ds_mysql' + const exportSpy = vi.fn().mockResolvedValue('/tmp/mysql-partial-ordered-result.csv') + vi.stubGlobal('go', { + main: { + App: { + ExportQueryResult: exportSpy, + }, + }, + }) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'id__2'], + rows: [ + { id: 1, id__2: 9 }, + { id: 2, id__2: 10 }, + ], + columnMeta: [ + { key: 'id', name: 'id', position: 0 }, + { key: 'id__2', name: 'id', position: 1 }, + ], + rowValues: [[1, 9]], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT u.id, o.id FROM users u JOIN orders o ON u.id = o.user_id') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + await wrapper.get('[data-testid="result-filter-export"]').trigger('click') + await flushPromises() + + expect(exportSpy).toHaveBeenCalledTimes(1) + const [, content] = exportSpy.mock.calls[0] || [] + expect(content).toContain('#,id,id') + expect(content).toContain('1,1,9') + expect(content).toContain('2,2,10') + }) +}) diff --git a/frontend/src/__tests__/console-result-visualization.test.ts b/frontend/src/__tests__/console-result-visualization.test.ts new file mode 100644 index 0000000..69f2151 --- /dev/null +++ b/frontend/src/__tests__/console-result-visualization.test.ts @@ -0,0 +1,131 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +const Dummy = { template: '
' } + +describe('ConsoleView result visualization', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + { path: '/visualization', name: 'visualization', component: Dummy }, + ], + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hides Visualization builder entry for SQL parity results', async () => { + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await router.isReady() + const rows = [ + { category: 'A', value: 10 }, + { category: 'B', value: 20 }, + { category: 'A', value: 5 }, + ] + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['category', 'value'], + rows, + rowCount: rows.length, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT category, value FROM sales') + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + + await executeButton.trigger('click') + await flushPromises() + + const visualizeButton = wrapper.find('[data-testid="result-visualize"]') + expect(visualizeButton.exists()).toBe(false) + expect(wrapper.find('[data-testid="result-visualization-builder"]').exists()).toBe(false) + expect(router.currentRoute.value.name).toBe('console') + }) + + it('does not show Visualization button for Redis results', async () => { + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [{ result: 'OK' }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const cliInput = wrapper.get('[data-testid="redis-cli-input"]') + await cliInput.setValue('GET foo') + await cliInput.trigger('keydown.enter') + await flushPromises() + + expect(wrapper.find('[data-testid="result-visualize"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-results-dialog-drag.test.ts b/frontend/src/__tests__/console-results-dialog-drag.test.ts new file mode 100644 index 0000000..669ab50 --- /dev/null +++ b/frontend/src/__tests__/console-results-dialog-drag.test.ts @@ -0,0 +1,82 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import ConsoleResultsContent from '@/views/console/components/ConsoleResultsContent.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console results dialog drag', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('moves the expanded results dialog when dragging the header', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [{ id: 'ds_mysql', name: 'DS', type: 'mysql', host: '', port: 0 } as any] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = wrapper.find('.console-monaco-editor__fallback') + expect(statementInput.exists()).toBe(true) + await statementInput.setValue('SELECT 1') + + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + await executeButton.trigger('click') + + await flushPromises() + + wrapper.findComponent(ConsoleResultsContent).vm.$emit('openExpanded') + + await flushPromises() + + const dialog = document.body.querySelector('[data-testid="results-dialog"]') as HTMLElement | null + expect(dialog).toBeTruthy() + const card = dialog!.querySelector('.dialog-card--results') as HTMLElement | null + expect(card).toBeTruthy() + + const head = card!.querySelector('.dialog-head') as HTMLElement | null + expect(head).toBeTruthy() + + const before = card!.style.transform + + head!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 100, clientY: 100, button: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 150, clientY: 120, button: 0 })) + window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 150, clientY: 120, button: 0 })) + + await flushPromises() + + expect(card!.style.transform).toContain('translate') + expect(card!.style.transform).not.toBe(before) + }) +}) diff --git a/frontend/src/__tests__/console-results-dialog-pagination.test.ts b/frontend/src/__tests__/console-results-dialog-pagination.test.ts new file mode 100644 index 0000000..85651b5 --- /dev/null +++ b/frontend/src/__tests__/console-results-dialog-pagination.test.ts @@ -0,0 +1,82 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import ConsoleResultsContent from '@/views/console/components/ConsoleResultsContent.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const mysqlDatasource = { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, +} as any + +describe('Console expanded results pagination', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps parity SQL results expandable into dialog mode', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + + const initialRows = Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValueOnce({ + columns: ['id'], + rows: initialRows, + rowCount: initialRows.length, + nextToken: 'token-200', + elapsedMs: 12, + }) + + const store = useAppStore() + store.datasources = [mysqlDatasource] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const statementInput = wrapper.find('.console-monaco-editor__fallback') + expect(statementInput.exists()).toBe(true) + await statementInput.setValue('SELECT * FROM users LIMIT 10000') + + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + expect(executeButton.exists()).toBe(true) + await executeButton.trigger('click') + await flushPromises() + + wrapper.findComponent(ConsoleResultsContent).vm.$emit('openExpanded') + await flushPromises() + + const dialog = document.body.querySelector('[data-testid="results-dialog"]') as HTMLElement | null + expect(dialog).toBeTruthy() + + expect(executeSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/__tests__/console-results-expanded-dialog-css.test.ts b/frontend/src/__tests__/console-results-expanded-dialog-css.test.ts new file mode 100644 index 0000000..4114442 --- /dev/null +++ b/frontend/src/__tests__/console-results-expanded-dialog-css.test.ts @@ -0,0 +1,35 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('expanded results dialog CSS', () => { + it('lays out expanded results content so the table can scroll', () => { + const css = loadStyleCss() + const block = css.match(/\.dialog-body--results\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('display: flex') + expect(block).toContain('flex-direction: column') + }) + + it('lets dialog results content fill the available height', () => { + const css = loadStyleCss() + const block = css.match(/\.console-results-content--dialog\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('flex: 1') + }) + + it('gives result meta actions a distinct visual affordance', () => { + const css = loadStyleCss() + const block = css.match(/\.result-meta-actions\s+\.btn\.ghost\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('background:') + expect(block).not.toContain('background: transparent') + }) +}) diff --git a/frontend/src/__tests__/console-row-mutation-flow.test.ts b/frontend/src/__tests__/console-row-mutation-flow.test.ts new file mode 100644 index 0000000..685ada2 --- /dev/null +++ b/frontend/src/__tests__/console-row-mutation-flow.test.ts @@ -0,0 +1,200 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { setAppLocale } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Console row mutation flow', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + setAppLocale('en') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const seedRowOpsMock = async () => { + const describeResponse = { + columns: [ + { name: 'id', dataType: 'int' }, + { name: 'name', dataType: 'varchar' }, + ], + indexes: [ + { name: 'PRIMARY', column: 'id', definition: 'PRIMARY KEY (`id`)' }, + ], + details: [], + } + vi.spyOn(api, 'listEntities').mockResolvedValue([ + { type: 'table', name: 'users', extras: {} } as any, + ]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'describeEntity').mockResolvedValue(describeResponse as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + columnMeta: [ + { key: 'id', name: 'id', position: 0 }, + { key: 'name', name: 'name', position: 1 }, + ], + rows: [ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + ], + rowValues: [ + [1, 'alice'], + [2, 'bob'], + ], + rowCount: 2, + elapsedMs: 5, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'DS', type: 'mysql', host: '', port: 0 } as any, + ] + store.selectedEntity = 'users' + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { plugins: [pinia] }, + }) + + await flushPromises() + + const statementInput = wrapper.find('.console-monaco-editor__fallback') + expect(statementInput.exists()).toBe(true) + await statementInput.setValue('SELECT id, name FROM users') + + const executeButton = wrapper.find('.editor-toolbar-sql-editor .execute-btn') + await executeButton.trigger('click') + await flushPromises() + await flushPromises() + await flushPromises() + + return { wrapper, executeSpy } + } + + it('renders the row-delete action column after running a single-table SELECT with a primary key', async () => { + const { wrapper } = await seedRowOpsMock() + + const deleteButtons = wrapper.findAll('[data-testid="result-row-delete"]') + expect(deleteButtons.length).toBe(2) + + const editableCells = wrapper.findAll('td.result-cell-editable') + expect(editableCells.length).toBeGreaterThan(0) + // name is editable, id (PK) is not + expect(wrapper.find('td[data-column-key="name"]').classes()).toContain('result-cell-editable') + expect(wrapper.find('td[data-column-key="id"]').classes()).not.toContain('result-cell-editable') + }) + + it('opens delete dialog and executes DELETE with WHERE on the primary key', async () => { + const { wrapper, executeSpy } = await seedRowOpsMock() + + const deleteButtons = wrapper.findAll('[data-testid="result-row-delete"]') + await deleteButtons[0].trigger('click') + await flushPromises() + + const dialog = wrapper.find('[data-testid="row-mutation-delete-dialog"]') + expect(dialog.exists()).toBe(true) + expect(wrapper.find('[data-testid="row-mutation-table"]').text()).toBe('users') + expect(wrapper.find('[data-testid="row-mutation-pk"]').text()).toBe('id = 1') + expect(wrapper.find('[data-testid="row-mutation-statement"]').text()).toContain('DELETE FROM') + + executeSpy.mockResolvedValueOnce({ rows: [], rowCount: 0, elapsedMs: 1 } as any) + await wrapper.find('[data-testid="row-mutation-confirm-delete"]').trigger('click') + await flushPromises() + + const lastCall = executeSpy.mock.calls[executeSpy.mock.calls.length - 1] + expect(String(lastCall[1])).toMatch(/DELETE FROM\s+users\s+WHERE\s+id\s*=\s*1/i) + expect(wrapper.find('[data-testid="row-mutation-delete-dialog"]').exists()).toBe(false) + }) + + it('opens update dialog on cell double-click and builds UPDATE with new value', async () => { + const { wrapper, executeSpy } = await seedRowOpsMock() + + const nameCell = wrapper.find('td[data-column-key="name"][data-row-index="1"]') + await nameCell.trigger('dblclick') + await flushPromises() + + const dialog = wrapper.find('[data-testid="row-mutation-update-dialog"]') + expect(dialog.exists()).toBe(true) + expect(wrapper.find('[data-testid="row-mutation-column"]').text()).toBe('name') + + const input = wrapper.find('[data-testid="row-mutation-new-value"]') + await input.setValue('neo') + await flushPromises() + + expect(wrapper.find('[data-testid="row-mutation-statement"]').text()).toMatch( + /UPDATE\s+users\s+SET\s+name\s*=\s*'neo'\s+WHERE\s+id\s*=\s*2/i, + ) + + executeSpy.mockResolvedValueOnce({ rows: [], rowCount: 0, elapsedMs: 1 } as any) + await wrapper.find('[data-testid="row-mutation-confirm-update"]').trigger('click') + await flushPromises() + + const lastCall = executeSpy.mock.calls[executeSpy.mock.calls.length - 1] + expect(String(lastCall[1])).toMatch(/UPDATE\s+users\s+SET\s+name\s*=\s*'neo'/i) + expect(wrapper.find('[data-testid="row-mutation-update-dialog"]').exists()).toBe(false) + }) + + it('cancels the dialog without executing when cancel is clicked', async () => { + const { wrapper, executeSpy } = await seedRowOpsMock() + const initialCallCount = executeSpy.mock.calls.length + + await wrapper.findAll('[data-testid="result-row-delete"]')[0].trigger('click') + await flushPromises() + await wrapper.find('[data-testid="row-mutation-cancel"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="row-mutation-delete-dialog"]').exists()).toBe(false) + expect(executeSpy.mock.calls.length).toBe(initialCallCount) + }) + + it('does not render row actions when the statement joins another table', async () => { + const describeResponse = { + columns: [{ name: 'id', dataType: 'int' }, { name: 'name', dataType: 'varchar' }], + indexes: [{ name: 'PRIMARY', column: 'id', definition: 'PRIMARY KEY (`id`)' }], + details: [], + } + vi.spyOn(api, 'listEntities').mockResolvedValue([ + { type: 'table', name: 'users', extras: {} } as any, + ]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'describeEntity').mockResolvedValue(describeResponse as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows: [{ id: 1, name: 'alice' }], + rowValues: [[1, 'alice']], + rowCount: 1, + elapsedMs: 4, + } as any) + + const store = useAppStore() + store.datasources = [{ id: 'ds_mysql', name: 'DS', type: 'mysql', host: '', port: 0 } as any] + store.selectedEntity = 'users' + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { plugins: [pinia] }, + }) + + await flushPromises() + const statementInput = wrapper.find('.console-monaco-editor__fallback') + await statementInput.setValue('SELECT u.id, u.name FROM users u JOIN orders o ON o.uid = u.id') + await wrapper.find('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="result-row-delete"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-sql-auto-pagination.test.ts b/frontend/src/__tests__/console-sql-auto-pagination.test.ts new file mode 100644 index 0000000..9ba8e7d --- /dev/null +++ b/frontend/src/__tests__/console-sql-auto-pagination.test.ts @@ -0,0 +1,262 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { tApp } from '@/modules/i18n/appI18n' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const mysqlDatasource = { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, +} as any + +const setupMysqlDatasource = () => { + const store = useAppStore() + store.datasources = [mysqlDatasource] + return store +} + +const mountConsoleView = async (pinia: ReturnType) => { + setupMysqlDatasource() + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + return wrapper +} + +const findButton = (wrapper: ReturnType, label: string) => + wrapper.findAll('button').find((btn) => btn.text() === label) + +const triggerResultScroll = async (wrapper: ReturnType, opts: { scrollTop: number; clientHeight: number; scrollHeight: number }) => { + const resultEl = wrapper.find('#result').element as HTMLElement + Object.defineProperty(resultEl, 'scrollTop', { value: opts.scrollTop, writable: true, configurable: true }) + Object.defineProperty(resultEl, 'clientHeight', { value: opts.clientHeight, configurable: true }) + Object.defineProperty(resultEl, 'scrollHeight', { value: opts.scrollHeight, configurable: true }) + await wrapper.find('#result').trigger('scroll') + await flushPromises() +} + +describe('ConsoleView SQL auto pagination', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('passes the statement and paging options without rewriting', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 12, + }) + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + expect(executeSpy.mock.calls[0]?.[1]).toBe('SELECT * FROM users') + expect(executeSpy.mock.calls[0]?.[3]).toBe('') + expect(executeSpy.mock.calls[0]?.[4]).toBe(200) + }) + + it('loads the next page when the first page hits page size', async () => { + const rows = Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows, + rowCount: rows.length, + nextToken: 'next-token', + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + columns: ['id'], + rows: [], + rowCount: 0, + elapsedMs: 12, + }) + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + await triggerResultScroll(wrapper, { scrollTop: 900, clientHeight: 200, scrollHeight: 1000 }) + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[1]).toBe('SELECT * FROM users') + expect(executeSpy.mock.calls[1]?.[3]).toBe('next-token') + expect(executeSpy.mock.calls[1]?.[4]).toBe(200) + }) + + it('keeps paging when the editor statement includes a trailing semicolon', async () => { + const rows = Array.from({ length: 201 }, (_, idx) => ({ id: idx + 1 })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows, + rowCount: rows.length, + nextToken: 'next-token', + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + columns: ['id'], + rows: [], + rowCount: 0, + elapsedMs: 12, + }) + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users;') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + await triggerResultScroll(wrapper, { scrollTop: 900, clientHeight: 200, scrollHeight: 1000 }) + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[1]).toBe('SELECT * FROM users;') + expect(executeSpy.mock.calls[1]?.[3]).toBe('next-token') + expect(executeSpy.mock.calls[1]?.[4]).toBe(200) + }) + + it('paginates when LIMIT exceeds 200', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 12, + }) + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users LIMIT 100000') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + const statement = executeSpy.mock.calls[0]?.[1] as string + expect(statement).toBe('SELECT * FROM users LIMIT 100000') + expect(executeSpy.mock.calls[0]?.[3]).toBe('') + expect(executeSpy.mock.calls[0]?.[4]).toBe(200) + }) + + it('uses footer pager in parity mode and keeps default SQL page size', async () => { + const initialRows = Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })) + const pageRows = Array.from({ length: 200 }, (_, idx) => ({ id: idx + 201 })) + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows: initialRows, + rowCount: initialRows.length, + nextToken: 'token-200', + elapsedMs: 12, + }) + .mockResolvedValueOnce({ + columns: ['id'], + rows: pageRows, + rowCount: pageRows.length, + nextToken: '', + elapsedMs: 12, + }) + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users LIMIT 10000') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + expect(wrapper.find('#sql-page-size').exists()).toBe(false) + + const nextButton = wrapper.find(`button[aria-label="${tApp('console.results.nextPageAria')}"]`) + expect(nextButton.exists()).toBe(true) + + await nextButton.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(executeSpy.mock.calls[1]?.[3]).toBe('token-200') + expect(executeSpy.mock.calls[1]?.[4]).toBe(200) + }) + + it('continues paging beyond 2000 rows when tokens are returned', async () => { + const pageSize = 200 + const pages = 11 + const executeSpy = vi.spyOn(api, 'executeStatement') + for (let page = 0; page < pages; page += 1) { + const rows = Array.from({ length: pageSize }, (_, idx) => ({ + id: page * pageSize + idx + 1, + })) + executeSpy.mockResolvedValueOnce({ + columns: ['id'], + rows, + rowCount: rows.length, + nextToken: page < pages - 1 ? `token-${page + 1}` : undefined, + elapsedMs: 12, + }) + } + + const wrapper = await mountConsoleView(pinia) + + await getConsoleStatementInput(wrapper).setValue('SELECT * FROM users') + const executeButton = findButton(wrapper, 'Execute') + expect(executeButton).toBeTruthy() + + await executeButton!.trigger('click') + await flushPromises() + + for (let page = 0; page < pages - 1; page += 1) { + await triggerResultScroll(wrapper, { scrollTop: 900, clientHeight: 200, scrollHeight: 1000 }) + } + + expect(executeSpy).toHaveBeenCalledTimes(pages) + }) +}) diff --git a/frontend/src/__tests__/console-sql-editor-parity-mode.test.ts b/frontend/src/__tests__/console-sql-editor-parity-mode.test.ts new file mode 100644 index 0000000..fb07482 --- /dev/null +++ b/frontend/src/__tests__/console-sql-editor-parity-mode.test.ts @@ -0,0 +1,3623 @@ +import { DOMWrapper, enableAutoUnmount, mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { tApp } from '@/modules/i18n/appI18n' +import ConsoleElasticResultsWorkspace from '@/views/console/components/elastic-results/ConsoleElasticResultsWorkspace.vue' + +const Dummy = { template: '
' } + +enableAutoUnmount(afterEach) + +const getStatementEditorInput = (wrapper: ReturnType) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} + +const findBodyTestId = (testId: string) => { + const el = document.body.querySelector(`[data-testid="${testId}"]`) + return el ? new DOMWrapper(el as Element) : null +} + +const getBodyTestId = (testId: string) => { + const node = findBodyTestId(testId) + if (!node) throw new Error(`Missing body node for ${testId}`) + return node +} + +const parseElasticStatementBody = (statement: string) => { + const normalized = String(statement || '').replace(/\r\n/g, '\n').trim() + const lines = normalized.split('\n') + if (lines.length <= 1) return {} + const body = lines.slice(1).join('\n').trim() + if (!body) return {} + try { + return JSON.parse(body) + } catch { + return {} + } +} + +const createElasticDeepPaginationExecuteMock = (options?: { + total?: number + pageSize?: number + sortField?: string +}) => { + const total = Math.max(0, Number(options?.total ?? 100000)) + const fallbackPageSize = Math.max(1, Number(options?.pageSize ?? 50)) + const sortField = String(options?.sortField || 'rank') + + return vi.fn(async (_id: string, statement: string) => { + const normalized = String(statement || '') + const lower = normalized.toLowerCase() + if (lower.includes('/_pit')) { + return { + columns: [], + rows: [{ id: 'pit-1' }], + rowCount: 1, + elapsedMs: 1, + } as any + } + + const body = parseElasticStatementBody(normalized) as Record + const size = Math.max( + 1, + Number( + body.size + ?? Number(/(?:[?&])size=(\d+)/i.exec(normalized)?.[1] || fallbackPageSize), + ), + ) + const rawSearchAfter = Array.isArray(body.search_after) ? body.search_after : null + const start = rawSearchAfter?.length + ? Math.max(1, Number(rawSearchAfter[0]) + 1) + : Math.max(1, Number(body.from ?? 0) + 1) + const rows = Array.from({ length: Math.max(0, Math.min(size, total - start + 1)) }, (_, idx) => { + const rank = start + idx + return { + _id: String(rank), + _index: 'demo', + sort: [rank], + _source: { + title: `Mock doc ${rank}`, + [sortField]: total - rank, + }, + } + }) + + return { + columns: [], + rows, + rowCount: total, + elapsedMs: 15, + detail: lower.includes('pit') ? { pitId: 'pit-1' } : undefined, + } as any + }) +} + +describe('Console sql-editor parity mode', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + ], + }) + + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await router.isReady() + + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('renders sql-editor style toolbar and result header for mysql', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + expect(wrapper.find('.console-shell.sql-editor-parity').exists()).toBe(true) + expect(wrapper.find('.list-toolbar').exists()).toBe(true) + expect(wrapper.find('.editor-toolbar-sql-editor').exists()).toBe(true) + expect(wrapper.find('.result-header-sql-editor').exists()).toBe(true) + expect(wrapper.find('.console-editor-results-splitter').exists()).toBe(true) + expect(wrapper.find('.console-statement-panel .console-actions').exists()).toBe(false) + expect(wrapper.find('.result-header-sql-editor p').text()).toContain('Select target then Execute') + expect(wrapper.find('.empty-tip-sql-editor').text()).toContain('Select target then Execute') + expect(wrapper.find('.editor-toolbar-sql-editor .toolbar-status').text()).toContain('MYSQL 8.0') + const executeBtn = wrapper.get('.editor-toolbar-sql-editor .execute-btn') + const explainBtn = wrapper.get('.editor-toolbar-sql-editor .explain-btn') + expect((executeBtn.element as HTMLButtonElement).disabled).toBe(true) + expect((explainBtn.element as HTMLButtonElement).disabled).toBe(true) + const refreshTopButton = wrapper.findAll('.list-toolbar .btn').find((btn) => btn.text() === 'Refresh Entities') + expect(refreshTopButton).toBeUndefined() + expect(wrapper.find('[data-testid="console-datasource-dropdown-trigger"]').exists()).toBe(true) + }) + + it('renders sql-editor style shell for dynamodb and keeps table-click as template-only', async () => { + await router.push({ name: 'console', params: { id: 'ds_ddb' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [{ label: 'Partition Key', value: 'user_id' }], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [{ pk: 'PK#1' }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + options: { region: 'us-east-1' }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + expect(wrapper.find('.console-shell.sql-editor-parity').exists()).toBe(true) + expect(wrapper.find('.editor-toolbar-sql-editor').exists()).toBe(true) + expect(wrapper.find('.result-header-sql-editor').exists()).toBe(true) + expect(wrapper.find('.result-footer-sql-editor').exists()).toBe(true) + expect(wrapper.find('.editor-toolbar-sql-editor .toolbar-status').text()).toMatch(/dynamo/i) + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain('FROM "orders"') + // Sample SQL must use the table's real partition key (from DescribeEntity), + // not the generic "pk" placeholder. + expect((statementInput.element as HTMLTextAreaElement).value).toContain("WHERE \"user_id\" = 'PK#...'") + expect(wrapper.find('.result-header-sql-editor p').text()).toContain('Click Execute') + expect(executeSpy).not.toHaveBeenCalled() + }) + + it('renders dynamodb execute rows in table view (parity with sql datasources)', async () => { + await router.push({ name: 'console', params: { id: 'ds_ddb' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [{ label: 'Partition Key', value: 'pk' }], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + rows: [ + { pk: 'USER#1', total: 120 }, + { pk: 'USER#2', status: 'PENDING' }, + ], + rowCount: 2, + elapsedMs: 2, + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + options: { region: 'us-east-1' }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue("SELECT * FROM \"orders\" WHERE \"pk\" = 'USER#1';") + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.find('.result-table-shell').exists()).toBe(true) + expect(wrapper.find('.result-table').exists()).toBe(true) + expect(wrapper.find('.mongo-result-shell').exists()).toBe(false) + expect(wrapper.find('.result-table thead').text()).toContain('pk') + expect(wrapper.find('.result-table thead').text()).toContain('status') + expect(wrapper.text()).toContain('USER#1') + }) + + it('auto-seeds first parity tab statement from first entity without auto-executing', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['table_0001'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain('SELECT') + expect((statementInput.element as HTMLTextAreaElement).value).toContain('table_0001') + expect(executeSpy).not.toHaveBeenCalled() + expect(wrapper.find('.result-header-sql-editor p').text()).toContain('Click Execute to run mock query') + }) + + it('quotes mysql identifiers when seeding parity statement from selected table', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['order-items'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM `order-items` ORDER BY id DESC LIMIT 50;', + ) + }) + + it('uses only mysql PRIMARY index columns for parity ORDER BY', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [ + { name: 'PRIMARY', column: 'id', unique: true }, + { name: 'status_pkey', column: 'status', unique: true }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const value = (statementInput.element as HTMLTextAreaElement).value + expect(value).toContain('SELECT * FROM orders ORDER BY id DESC LIMIT 50;') + expect(value).not.toContain('status DESC') + }) + + it('uses postgresql primary key ordering when seeding parity statement from selected table', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [ + { name: 'orders_pkey', column: 'id', unique: true }, + { name: 'PRIMARY', column: 'id', unique: true, definition: 'CONSTRAINT orders_pkey PRIMARY KEY' }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM public.orders ORDER BY id DESC LIMIT 50;', + ) + }) + + it('uses postgresql primary key ordering when primary constraint index is renamed', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [ + { name: 'custom_pkey', column: 'id', unique: true }, + { name: 'PRIMARY', column: 'id', unique: true, definition: 'CONSTRAINT custom_pkey PRIMARY KEY' }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM public.orders ORDER BY id DESC LIMIT 50;', + ) + }) + + it('prefers constraint-backed PRIMARY metadata when duplicate PRIMARY indexes exist', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [ + { + name: 'PRIMARY', + column: 'status', + unique: true, + definition: 'CREATE UNIQUE INDEX "PRIMARY" ON public.orders USING btree (status)', + }, + { + name: 'PRIMARY', + column: 'id', + unique: true, + definition: 'CONSTRAINT custom_pkey PRIMARY KEY', + }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const value = (statementInput.element as HTMLTextAreaElement).value + expect(value).toContain('SELECT * FROM public.orders ORDER BY id DESC LIMIT 50;') + expect(value).not.toContain('ORDER BY status DESC') + }) + + it('quotes case-sensitive postgresql primary key columns in parity ORDER BY', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'UserID', dataType: 'bigint', nullable: 'NO' }], + indexes: [ + { name: 'orders_pkey', column: '"UserID"', unique: true }, + { name: 'PRIMARY', column: '"UserID"', unique: true, definition: 'CONSTRAINT orders_pkey PRIMARY KEY' }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM public.orders ORDER BY "UserID" DESC LIMIT 50;', + ) + }) + + it('allows quoted postgresql primary key identifiers with special characters in parity ORDER BY', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'User-ID', dataType: 'bigint', nullable: 'NO' }, + ], + indexes: [ + { name: 'orders_pkey', column: '"User-ID"', unique: true }, + { name: 'PRIMARY', column: '"User-ID"', unique: true, definition: 'CONSTRAINT orders_pkey PRIMARY KEY' }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM public.orders ORDER BY "User-ID" DESC LIMIT 50;', + ) + }) + + it('preserves quoted postgresql primary key identifiers containing dots in parity ORDER BY', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'tenant.id', dataType: 'bigint', nullable: 'NO' }], + indexes: [ + { name: 'orders_pkey', column: '"tenant.id"', unique: true }, + { name: 'PRIMARY', column: '"tenant.id"', unique: true, definition: 'CONSTRAINT orders_pkey PRIMARY KEY' }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain( + 'SELECT * FROM public.orders ORDER BY "tenant.id" DESC LIMIT 50;', + ) + }) + + it('skips postgresql parity ORDER BY when only _pkey index metadata is present', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [ + { + name: 'orders_pkey', + unique: true, + definition: 'CREATE UNIQUE INDEX orders_pkey ON public.orders USING btree (id)', + }, + ], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const value = (statementInput.element as HTMLTextAreaElement).value + expect(value).toContain('SELECT * FROM public.orders LIMIT 50;') + expect(value).not.toContain('ORDER BY') + }) + + it('skips postgresql parity ORDER BY when pkey-like index metadata is not trustworthy', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [{ name: 'shadow_pkey', column: 'status', unique: true }], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const value = (statementInput.element as HTMLTextAreaElement).value + expect(value).toContain('SELECT * FROM public.orders LIMIT 50;') + expect(value).not.toContain('ORDER BY') + }) + + it('skips postgresql parity ORDER BY when inferred pkey columns are expressions', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [{ name: 'orders_pkey', column: 'lower(status)', unique: true }], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const value = (statementInput.element as HTMLTextAreaElement).value + expect(value).toContain('SELECT * FROM public.orders LIMIT 50;') + expect(value).not.toContain('ORDER BY') + }) + + it('shows Analyze toggle in postgres parity toolbar and sends analyze=true on explain', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['public.orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'orders_pkey', column: 'id', unique: true }], + } as any) + const explainSpy = vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + detail: [], + stages: [], + indexes: [], + totalDocsExamined: 1, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const analyzeToggle = wrapper.get('.editor-toolbar-sql-editor .analyze-toggle-sql-editor input[type="checkbox"]') + expect((analyzeToggle.element as HTMLInputElement).checked).toBe(false) + + await analyzeToggle.setValue(true) + await wrapper.get('.editor-toolbar-sql-editor .explain-btn').trigger('click') + await flushPromises() + + expect(explainSpy).toHaveBeenCalledWith('ds_pg', expect.any(String), true, '') + }) + + it('keeps parity result area minimal (no extra actions/toolbar) and shows sql-editor footer', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue(['table_0001']) + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + detail: 'ok', + cost: 1.2, + rowEstimate: 3, + warnings: [], + plan: [], + } as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows: [ + { id: 1, name: 'row_1' }, + { id: 2, name: 'row_2' }, + { id: 3, name: 'row_3' }, + ], + rowCount: 3, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + expect(wrapper.find('.result-footer-sql-editor .pager').exists()).toBe(false) + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT * FROM table_0001 LIMIT 3;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.find('.result-toolbar').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-visualize"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-expand"]').exists()).toBe(false) + expect(wrapper.find('.statement-runner').exists()).toBe(false) + expect(wrapper.find('.result-footer-sql-editor').exists()).toBe(true) + expect(wrapper.find('.result-footer-sql-editor .pager').exists()).toBe(true) + }) + + it('uses parity footer pager controls for SQL pagination', async () => { + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows: Array.from({ length: 200 }, (_, idx) => ({ id: idx + 1 })), + rowCount: 200, + elapsedMs: 12, + nextToken: 'token-200', + } as any) + .mockResolvedValueOnce({ + columns: ['id'], + rows: Array.from({ length: 200 }, (_, idx) => ({ id: idx + 201 })), + rowCount: 400, + elapsedMs: 13, + nextToken: '', + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT * FROM table_0001 LIMIT 10000;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const pager = wrapper.get('.result-footer-sql-editor .pager') + const prevButton = pager.get('button[aria-label="Previous page"]') + const currentButton = pager.get('button[aria-label="Current page"]') + const nextButton = pager.get('button[aria-label="Next page"]') + + expect(currentButton.text()).toBe('1') + expect((prevButton.element as HTMLButtonElement).disabled).toBe(true) + + await nextButton.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(currentButton.text()).toBe('2') + expect((prevButton.element as HTMLButtonElement).disabled).toBe(false) + + await prevButton.trigger('click') + await flushPromises() + + expect(currentButton.text()).toBe('1') + expect((prevButton.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('executes the latest appended statement after clicking another entity in parity mode', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders', 'users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 2, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const textarea = statementInput.element as HTMLTextAreaElement + textarea.setSelectionRange(0, 0) + await statementInput.trigger('click') + await statementInput.trigger('keyup') + + const usersEntity = wrapper.findAll('.entity-item').find((node) => node.text().includes('users')) + expect(usersEntity).toBeTruthy() + await usersEntity!.trigger('click') + await flushPromises() + + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + const executed = String(executeSpy.mock.calls[0]?.[1] || '') + expect(executed).toContain('users') + expect(executed).not.toContain('orders') + }) + + it('runs all statements from the parity execute all button', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 2, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 1;\nSELECT 2;') + await wrapper.get('.editor-toolbar-sql-editor .execute-all-btn').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(String(executeSpy.mock.calls[0]?.[1] || '')).toContain('SELECT 1') + expect(String(executeSpy.mock.calls[1]?.[1] || '')).toContain('SELECT 2') + }) + + it('runs all statements from the parity explain all button', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + const explainSpy = vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + detail: [], + stages: [], + indexes: [], + totalDocsExamined: 1, + } as any) + const appendHistorySpy = vi.spyOn(api, 'appendHistory').mockResolvedValue({} as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT 1;\nSELECT 2;') + await wrapper.get('.editor-toolbar-sql-editor .explain-all-btn').trigger('click') + await flushPromises() + await flushPromises() + + expect(explainSpy).toHaveBeenCalledTimes(2) + expect(appendHistorySpy).not.toHaveBeenCalled() + expect(String(explainSpy.mock.calls[0]?.[1] || '')).toContain('SELECT 1') + expect(String(explainSpy.mock.calls[1]?.[1] || '')).toContain('SELECT 2') + expect(wrapper.find('.result-actions-sql-editor select').exists()).toBe(false) + expect(wrapper.find('.result-actions-sql-editor input').exists()).toBe(false) + expect(wrapper.find('.result-actions-sql-editor button').exists()).toBe(false) + }) + + it('uses sql-editor tab naming and mounts monaco fallback editor in test mode', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const activeTab = wrapper.find('.statement-tab--sql-editor.active') + expect(activeTab.exists()).toBe(true) + expect(activeTab.text()).toContain('Query 1') + expect(wrapper.find('.console-monaco-editor__fallback').exists()).toBe(true) + }) + + it('updates toolbar line and column indicator from caret position', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('SELECT 1;\nSELECT 2;') + + const textarea = statementInput.element as HTMLTextAreaElement + const caretPos = 'SELECT 1;\nSEL'.length + textarea.setSelectionRange(caretPos, caretPos) + await statementInput.trigger('click') + await statementInput.trigger('keyup') + await flushPromises() + + expect(wrapper.find('.editor-toolbar-sql-editor .toolbar-status').text()).toContain('Ln 2, Col 4') + }) + + it('keeps only one default query tab when datasource is already selected before console mount', async () => { + const store = useAppStore() + const datasource = { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any + store.datasources = [datasource] + store.current = datasource + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const tabs = wrapper.findAll('.statement-tab--sql-editor') + expect(tabs.length).toBe(1) + expect(tabs[0]?.text()).toContain('Query 1') + }) + + it('shows query tabs for elasticsearch parity mode', async () => { + const store = useAppStore() + const datasource = { id: 'ds_es', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any + store.datasources = [datasource] + store.current = datasource + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const tabs = wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs).toHaveLength(1) + expect(tabs[0]?.text()).toContain('Query 1') + expect(wrapper.find('[data-testid="statement-tab-add"]').exists()).toBe(true) + }) + + it('shows a dedicated chromadb builder instead of the generic parity toolbar', async () => { + await router.push({ name: 'console', params: { id: 'ds_chroma' } }) + await router.isReady() + + const store = useAppStore() + const datasource = { id: 'ds_chroma', name: 'Chroma', type: 'chromadb', host: '', port: 8000 } as any + store.datasources = [datasource] + store.current = datasource + store.selectedEntity = 'docs' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="chroma-dsl-workspace"]').exists()).toBe(true) + expect(wrapper.find('.console-shell.chroma-stitch').exists()).toBe(true) + expect(wrapper.find('.console-statement-panel--chroma-stitch').exists()).toBe(true) + expect(wrapper.find('.editor-toolbar-sql-editor').exists()).toBe(false) + expect(wrapper.find('[data-testid="chroma-dsl-run-search"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="chroma-dsl-id-list"]').exists()).toBe(true) + expect(wrapper.find('#chroma-live-dsl-toggle').exists()).toBe(true) + }) + + it('blocks chromadb similarity search until a query input is provided', async () => { + await router.push({ name: 'console', params: { id: 'ds_chroma' } }) + await router.isReady() + + vi.spyOn(api, 'listEntities').mockResolvedValue(['docs'] as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 5, + } as any) + + const store = useAppStore() + const datasource = { id: 'ds_chroma', name: 'Chroma', type: 'chromadb', host: '', port: 8000 } as any + store.datasources = [datasource] + store.current = datasource + store.selectedEntity = 'docs' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-mode-query"]').trigger('click') + await flushPromises() + + const runButton = wrapper.get('[data-testid="chroma-dsl-run-search"]') + expect((runButton.element as HTMLButtonElement).disabled).toBe(true) + expect(wrapper.find('[data-testid="chroma-dsl-query-embeddings"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Add at least one query input') + + await runButton.trigger('click') + await flushPromises() + expect(executeSpy).not.toHaveBeenCalled() + + await wrapper.get('[data-testid="chroma-dsl-query-embeddings"]').setValue('[0.1, 0.2, 0.3]') + await flushPromises() + + const liveDslCheckbox = wrapper.get('#chroma-live-dsl-toggle') + await liveDslCheckbox.setValue(true) + await flushPromises() + expect(wrapper.find('.chroma-dsl-drawer').exists()).toBe(true) + + await runButton.trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(executeSpy.mock.calls[0]?.[1]).toContain('"query_embeddings"') + expect(executeSpy.mock.calls[0]?.[1]).toContain('"distances"') + }) + + it('keeps Analyze label compact, supports close button, and allows tab rename by double click', async () => { + await router.push({ name: 'console', params: { id: 'ds_pg' } }) + await router.isReady() + + const store = useAppStore() + store.datasources = [ + { id: 'ds_pg', name: 'PostgreSQL', type: 'postgresql', host: '', port: 5432 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + expect(wrapper.get('.analyze-toggle-sql-editor span').text()).toBe('Analyze') + + await wrapper.get('[data-testid="statement-tab-add"]').trigger('click') + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + expect(tabs()).toHaveLength(2) + expect(tabs()[0]?.text()).toContain('Query 1') + expect(tabs()[1]?.text()).toContain('Query 2') + + const closeButtons = () => wrapper.findAll('[data-testid="statement-tab-close"]') + expect(closeButtons()).toHaveLength(2) + + await tabs()[1]!.trigger('dblclick') + await flushPromises() + + const renameInput = wrapper.get('[data-testid="statement-tab-rename-input"]') + expect(renameInput.attributes('autocapitalize')).toBe('off') + expect(renameInput.attributes('autocorrect')).toBe('off') + expect(renameInput.attributes('spellcheck')).toBe('false') + await renameInput.setValue('Orders Query') + await renameInput.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(tabs()[1]?.text()).toContain('Orders Query') + + await closeButtons()[1]!.trigger('click') + await flushPromises() + + expect(tabs()).toHaveLength(1) + expect(wrapper.findAll('[data-testid="statement-tab-close"]')).toHaveLength(0) + }) + + it('shows schema-derived filter fields before executing query results', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const fieldSelect = getBodyTestId('result-filter-field') + const options = fieldSelect.findAll('.field-option-name').map((option) => option.text()) + expect(options).toContain('id') + expect(options).toContain('nickname') + }) + + it('shows warning notice when clicking filter without selected target', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: [], + cursor: '', + done: true, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const trigger = wrapper.get('[data-testid="result-filter-trigger"]') + expect((trigger.element as HTMLButtonElement).disabled).toBe(false) + await trigger.trigger('click') + await flushPromises() + + expect(store.notice.message).toBe(tApp('console.results.filterNeedsTarget')) + expect(store.notice.type).toBe('warning') + expect(findBodyTestId('result-filter-panel')).toBeNull() + }) + + it('builds and executes a new statement when searching with parity result filters', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'nickname'], + rows: [{ id: 2, nickname: 'neo' }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const nicknameOption = getBodyTestId('result-filter-field') + .findAll('.result-filter-field-option') + .find((node) => node.text().includes('nickname')) + expect(nicknameOption).toBeTruthy() + await nicknameOption!.trigger('click') + await getBodyTestId('result-filter-operator').setValue('contains') + await getBodyTestId('result-filter-value').setValue('neo') + await getBodyTestId('result-filter-apply').trigger('click') + await flushPromises() + + await wrapper.get('[data-testid="result-filter-search"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + const executed = String(executeSpy.mock.calls[0]?.[1] || '') + expect(executed).toContain('FROM users') + expect(executed).toContain('nickname') + expect(executed).toContain('WHERE') + expect((getStatementEditorInput(wrapper).element as HTMLTextAreaElement).value).toContain('WHERE') + }) + + it('uses stitch-like filter affordances in parity result toolbar', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + const trigger = wrapper.get('[data-testid="result-filter-trigger"]') + await trigger.trigger('click') + await flushPromises() + + expect(trigger.classes()).toContain('is-active') + // Filter trigger should live in the dedicated filter toolbar, not in the result header actions. + expect(wrapper.find('.result-actions-sql-editor [data-testid="result-filter-trigger"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-filter-add-input"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-filter-clear-all"]').exists()).toBe(true) + const panel = getBodyTestId('result-filter-panel') + expect(panel.classes()).toContain('result-filter-popover') + expect(panel.attributes('data-step')).toBe('field') + expect(getBodyTestId('result-filter-field').classes()).toContain('result-filter-field-list') + expect(document.body.querySelector('.result-filter-panel-body')).toBeTruthy() + expect(document.body.querySelector('.result-filter-panel-actions')).toBeTruthy() + expect(getBodyTestId('result-filter-apply').classes()).toContain('result-filter-apply') + expect(getBodyTestId('result-filter-cancel').classes()).toContain('result-filter-cancel') + expect(document.activeElement?.getAttribute('data-testid')).toBe('result-filter-field-search') + + // Selecting a field advances to the editor step where operator/value controls live. + const nicknameOption = getBodyTestId('result-filter-field') + .findAll('.result-filter-field-option') + .find((node) => node.text().includes('nickname')) + expect(nicknameOption?.exists()).toBe(true) + await nicknameOption!.trigger('click') + await flushPromises() + + expect(getBodyTestId('result-filter-panel').attributes('data-step')).toBe('editor') + expect(getBodyTestId('result-filter-panel').text()).toContain(tApp('console.results.filterOperatorLabel')) + expect(getBodyTestId('result-filter-panel').text()).toContain(tApp('console.results.filterValueLabel')) + expect(findBodyTestId('result-filter-step-back')).not.toBeNull() + expect(getBodyTestId('result-filter-step-back').text()).toContain('nickname') + + // Step-back returns to the field picker and clears the search keyword. + await getBodyTestId('result-filter-step-back').trigger('click') + await flushPromises() + expect(getBodyTestId('result-filter-panel').attributes('data-step')).toBe('field') + expect(findBodyTestId('result-filter-field-search')).not.toBeNull() + } finally { + wrapper.unmount() + } + }) + + it('renders the parity filter popover under document.body so small result panes cannot clip it', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const panel = getBodyTestId('result-filter-panel').element as HTMLElement + expect(panel.parentElement).toBe(document.body) + } finally { + wrapper.unmount() + } + }) + + it('renders the teleported filter popover without copying sql-editor isolated tokens', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const panel = getBodyTestId('result-filter-panel').element as HTMLElement + // The popover now relies on global theme tokens via :root inheritance, not copied sql-editor isolated tokens. + const leakedTokens = [ + '--sql-editor-bg', + '--sql-editor-surface', + '--sql-editor-surface-soft', + '--sql-editor-border', + '--sql-editor-text', + '--sql-editor-muted', + '--sql-editor-button-bg-start', + '--sql-editor-button-bg-end', + '--sql-editor-button-text', + '--sql-editor-placeholder', + ] + for (const token of leakedTokens) { + expect(panel.style.getPropertyValue(token)).toBe('') + } + // Positioning vars still flow through inline style. + expect(panel.style.getPropertyValue('--result-filter-arrow-left')).not.toBe('') + } finally { + wrapper.unmount() + } + }) + + it('repositions the add-filter popover above the trigger when the viewport is too short', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const previousInnerHeight = window.innerHeight + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: 620, + }) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const triggerEl = wrapper.get('[data-testid="result-filter-trigger"]').element as HTMLElement + const panelEl = getBodyTestId('result-filter-panel').element as HTMLElement + + const rect = (top: number, left: number, width: number, height: number) => ({ + x: left, + y: top, + top, + left, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + }) as DOMRect + + vi.spyOn(triggerEl, 'getBoundingClientRect').mockImplementation(() => rect(551, 336, 72, 29)) + vi.spyOn(panelEl, 'getBoundingClientRect').mockImplementation(() => rect(0, 0, 248, 190)) + + window.dispatchEvent(new Event('resize')) + await flushPromises() + + expect(getBodyTestId('result-filter-panel').attributes('data-placement')).toBe('above') + } finally { + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: previousInnerHeight, + }) + wrapper.unmount() + } + }) + + it('adds a new filter instead of overwriting after switching from edit mode to add mode', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const chips = () => wrapper.findAll('.result-filter-chip-shell') + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + const nicknameOption = getBodyTestId('result-filter-field') + .findAll('.result-filter-field-option') + .find((node) => node.text().includes('nickname')) + expect(nicknameOption).toBeTruthy() + await nicknameOption!.trigger('click') + await getBodyTestId('result-filter-operator').setValue('contains') + await getBodyTestId('result-filter-value').setValue('neo') + await getBodyTestId('result-filter-apply').trigger('click') + await flushPromises() + + expect(chips()).toHaveLength(1) + + await wrapper.get('.result-filter-chip').trigger('click') + await flushPromises() + expect(wrapper.get('.result-filter-chip-shell').classes()).toContain('is-editing') + expect(getBodyTestId('result-filter-panel').get('.result-filter-popover-badge').text()).toContain('nickname') + expect(getBodyTestId('result-filter-apply').text()).toBe(tApp('console.results.filterUpdate')) + // Edit mode should not allow changing the field; only operator/value are editable. + expect(findBodyTestId('result-filter-field-search')).toBeNull() + expect(findBodyTestId('result-filter-field')).toBeNull() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + expect(getBodyTestId('result-filter-apply').text()).toBe(tApp('console.results.filterApply')) + + const idOption = getBodyTestId('result-filter-field') + .findAll('.result-filter-field-option') + .find((node) => node.text().includes('id')) + expect(idOption).toBeTruthy() + await idOption!.trigger('click') + await getBodyTestId('result-filter-operator').setValue('eq') + await getBodyTestId('result-filter-value').setValue('2') + await getBodyTestId('result-filter-apply').trigger('click') + await flushPromises() + + expect(chips()).toHaveLength(2) + const chipTexts = wrapper.findAll('.result-filter-chip').map((node) => node.text()) + expect(chipTexts.some((text) => text.includes('nickname') && text.includes('neo'))).toBe(true) + expect(chipTexts.some((text) => text.includes('id') && text.includes('2'))).toBe(true) + }) + + it('shows the full filter condition on hover and lets users copy it', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'nickname', dataType: 'varchar', nullable: 'YES' }, + ], + indexes: [{ name: 'PRIMARY', column: 'nickname', unique: false }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + + const nicknameOption = getBodyTestId('result-filter-field') + .findAll('.result-filter-field-option') + .find((node) => node.text().includes('nickname')) + expect(nicknameOption).toBeTruthy() + await nicknameOption!.trigger('click') + await getBodyTestId('result-filter-operator').setValue('eq') + await getBodyTestId('result-filter-value').setValue('neo') + await getBodyTestId('result-filter-apply').trigger('click') + await flushPromises() + + await wrapper.get('.result-filter-chip-shell').trigger('mouseenter') + await flushPromises() + + expect(wrapper.get('[data-testid="result-filter-chip-hover-card"]').text()).toContain('nickname = neo') + + await wrapper.get('.result-filter-chip-shell').trigger('mouseleave') + await flushPromises() + + expect(wrapper.find('[data-testid="result-filter-chip-hover-card"]').exists()).toBe(true) + + await wrapper.get('[data-testid="result-filter-chip-copy"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('nickname = neo') + expect(store.notice.message).toBe(tApp('common.copied')) + } finally { + wrapper.unmount() + } + }) + + it('closes the parity filter popover on escape key', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + expect(findBodyTestId('result-filter-panel')).not.toBeNull() + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await flushPromises() + + expect(findBodyTestId('result-filter-panel')).toBeNull() + }) + + it('closes the teleported parity filter popover when explain mode hides filter UX', async () => { + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ + items: ['users'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO' }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + vi.spyOn(api, 'explainStatement').mockResolvedValue({ + usesIndex: true, + detail: [], + stages: [], + indexes: [], + totalDocsExamined: 1, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + try { + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="result-filter-trigger"]').trigger('click') + await flushPromises() + expect(findBodyTestId('result-filter-panel')).not.toBeNull() + + await wrapper.get('.editor-toolbar-sql-editor .explain-btn').trigger('click') + await flushPromises() + await flushPromises() + + expect(wrapper.find('[data-testid="result-filter-trigger"]').exists()).toBe(false) + expect(findBodyTestId('result-filter-panel')).toBeNull() + } finally { + wrapper.unmount() + } + }) + + it('resets splitters to sql-editor defaults on double click', async () => { + const storage = { + getItem: vi.fn((key: string) => { + if (key === 'fd_console_split') return '340' + if (key === 'fd_console_editor_split') return '420' + return null + }), + setItem: vi.fn(), + } + vi.stubGlobal('localStorage', storage as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const shell = wrapper.get('.console-shell') + const editorShell = wrapper.get('.console-editor-results-shell') + const initialShellStyle = shell.attributes('style') + const initialEditorStyle = editorShell.attributes('style') + expect(initialShellStyle).not.toContain('--console-left: 250px;') + expect(initialEditorStyle).not.toContain('--console-editor-height: 360px;') + + await wrapper.get('.console-splitter').trigger('dblclick') + await wrapper.get('.console-editor-results-splitter').trigger('dblclick') + await flushPromises() + + expect(shell.attributes('style')).toContain('--console-left: 250px;') + expect(editorShell.attributes('style')).toContain('--console-editor-height:') + expect(storage.setItem).toHaveBeenCalledWith('fd_console_split', '250') + expect(storage.setItem).toHaveBeenCalledWith('fd_console_editor_split', expect.any(String)) + }) + + it('re-clamps restored editor split after switching from redis to sql shell', async () => { + const storage = { + getItem: vi.fn((key: string) => { + if (key === 'fd_console_editor_split') return '2000' + return null + }), + setItem: vi.fn(), + } + vi.stubGlobal('localStorage', storage as any) + + const store = useAppStore() + const redisDatasource = { id: 'ds_redis', name: 'Redis', type: 'redis', host: '', port: 6379 } as any + const mysqlDatasource = { id: 'ds_mysql2', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any + store.datasources = [ + redisDatasource, + mysqlDatasource, + ] + store.current = redisDatasource + + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + expect(wrapper.find('.console-shell').exists()).toBe(false) + + await router.push({ name: 'console', params: { id: 'ds_mysql2' } }) + await flushPromises() + await flushPromises() + + const editorShellStyle = wrapper.get('.console-editor-results-shell').attributes('style') + const heightMatch = editorShellStyle.match(/--console-editor-height:\s*(\d+)px/) + expect(heightMatch).toBeTruthy() + expect(Number(heightMatch?.[1] || 0)).toBeLessThan(600) + }) + + it('renders dedicated elastic result workspace for elastic document results in parity mode', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'demo', _source: { title: 'Mock doc A', score: 1.0 } }, + { _id: '2', _index: 'demo', _source: { title: 'Mock doc B', score: 0.9 } }, + ], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-view-list"]').exists()).toBe(true) + expect(wrapper.find('.sql-editor-json-tree-wrap').exists()).toBe(false) + }) + + it('renders dedicated chromadb result workspace and hides the generic parity footer', async () => { + await router.push({ name: 'console', params: { id: 'ds_chroma' } }) + await router.isReady() + + vi.spyOn(api, 'listEntities').mockResolvedValue(['docs'] as any) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { id: 'doc-1', document: 'Alpha', metadata: { topic: 'intro' }, distance: 0.12 }, + { id: 'doc-2', document: 'Beta', metadata: { topic: 'guide' }, distance: 0.34 }, + ], + rowCount: 2, + elapsedMs: 9, + } as any) + + const store = useAppStore() + const datasource = { id: 'ds_chroma', name: 'Chroma', type: 'chromadb', host: '', port: 8000 } as any + store.datasources = [datasource] + store.current = datasource + store.selectedEntity = 'docs' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="chroma-dsl-mode-query"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-query-embeddings"]').setValue('[0.1, 0.2]') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="chroma-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="chroma-view-list"]').exists()).toBe(true) + expect(wrapper.find('.result-footer-sql-editor').exists()).toBe(false) + expect(wrapper.find('.sql-editor-json-tree-wrap').exists()).toBe(false) + + wrapper.findComponent({ name: 'ConsoleChromaDslWorkspace' }).vm.$emit('update:statement', 'GET /health\n{}') + await flushPromises() + + expect(wrapper.find('[data-testid="chroma-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('.result-footer-sql-editor').exists()).toBe(false) + }) + + it('does not offer next-page controls for chromadb query results', async () => { + await router.push({ name: 'console', params: { id: 'ds_chroma' } }) + await router.isReady() + + vi.spyOn(api, 'listEntities').mockResolvedValue(['docs'] as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { id: 'doc-1', document: 'Alpha', metadata: { topic: 'intro' }, distance: 0.12 }, + { id: 'doc-2', document: 'Beta', metadata: { topic: 'guide' }, distance: 0.34 }, + ], + rowCount: 6, + elapsedMs: 9, + } as any) + + const store = useAppStore() + const datasource = { id: 'ds_chroma', name: 'Chroma', type: 'chromadb', host: '', port: 8000 } as any + store.datasources = [datasource] + store.current = datasource + store.selectedEntity = 'docs' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="chroma-dsl-mode-query"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-query-embeddings"]').setValue('[0.1, 0.2]') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-testid="chroma-page-next"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="chroma-page-2"]').exists()).toBe(false) + }) + + it('uses the original chromadb get limit as the paging stride', async () => { + await router.push({ name: 'console', params: { id: 'ds_chroma' } }) + await router.isReady() + + vi.spyOn(api, 'listEntities').mockResolvedValue(['docs'] as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, stmt: string) => { + const normalized = String(stmt || '').replace(/\r\n/g, '\n') + const body = parseElasticStatementBody(normalized) as Record + if (executeSpy.mock.calls.length === 1) { + return { + columns: [], + rows: [{ id: 'doc-1', document: 'Alpha', metadata: { topic: 'intro' } }], + rowCount: 5, + elapsedMs: 9, + } as any + } + return { + columns: [], + rows: [{ id: `offset-${body.offset}`, document: 'Paged', metadata: { topic: 'paged' } }], + rowCount: 5, + elapsedMs: 11, + } as any + }) + + const store = useAppStore() + const datasource = { id: 'ds_chroma', name: 'Chroma', type: 'chromadb', host: '', port: 8000 } as any + store.datasources = [datasource] + store.current = datasource + store.selectedEntity = 'docs' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + wrapper.findComponent({ name: 'ConsoleChromaDslWorkspace' }).vm.$emit( + 'update:statement', + 'POST /collections/docs/get\n{\n "limit": 2,\n "include": ["documents", "metadatas"]\n}', + ) + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-mode-get"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-limit"]').setValue('2') + await flushPromises() + await wrapper.get('[data-testid="chroma-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="chroma-page-next"]').exists()).toBe(true) + await wrapper.get('[data-testid="chroma-page-next"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + const secondStatement = String(executeSpy.mock.calls[1]?.[1] || '') + const secondBody = parseElasticStatementBody(secondStatement) as Record + expect(secondBody.limit).toBe(2) + expect(secondBody.offset).toBe(2) + expect(wrapper.text()).toContain('offset-2') + }) + + it('falls back to regular parity results for elastic non-search responses', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['index', 'docs.count'], + rows: [{ index: 'futrixdata-demo-1', 'docs.count': 2 }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /_cat/indices?format=json') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(false) + expect(wrapper.find('.result-table').exists()).toBe(true) + expect(wrapper.find('.result-footer-sql-editor .pager').exists()).toBe(true) + expect( + wrapper.find(`.result-footer-sql-editor button[aria-label="${tApp('console.results.currentPageAria')}"]`).text(), + ).toBe('1') + expect(wrapper.text()).toContain('futrixdata-demo-1') + }) + + it('falls back to regular parity results for elastic _search responses without hit documents', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'title'], + rows: [{ id: '1', title: 'Mock doc A' }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(false) + expect(wrapper.find('.result-table').exists()).toBe(true) + expect(wrapper.text()).toContain('Mock doc A') + }) + + it('uses executed elastic request target to resolve visible summary fields', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'futrixdata-demo-2', _source: { title: 'Mock doc A', score: 1.0 } }, + ], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + store.elasticsearchFieldSelections['futrixdata-demo-1'] = ['wrong_field'] + store.elasticsearchFieldSelections['futrixdata-demo-2'] = ['title'] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-2/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.text()).toContain('TITLE') + expect(workspace.text()).not.toContain('WRONG_FIELD') + }) + + it('falls back to returned elastic fields when mapping columns do not match the current response', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'futrixdata-demo-2', _source: { scripted_only: 'derived', score: 1.0 } }, + ], + rowCount: 1, + elapsedMs: 12, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'title' }, { name: 'status' }], + indexes: [], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-2' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-2/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.text()).toContain('SCRIPTED ONLY') + expect(workspace.text()).toContain('derived') + expect(workspace.text()).not.toContain('TITLE') + expect(workspace.text()).not.toContain('UNKNOWNSTATUS') + }) + + it('does not reuse selected entity field mappings for global elastic searches', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'futrixdata-demo-2', _source: { title: 'Mock doc A', score: 1.0 } }, + ], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + store.elasticsearchFieldSelections['futrixdata-demo-1'] = ['wrong_field'] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.text()).toContain('TITLE') + expect(workspace.text()).not.toContain('WRONG_FIELD') + }) + + it('loads mappings for the auto-selected elastic target before opening the DSL field picker', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement || '').includes('/_cat/indices?format=json&h=index,health,store.size')) { + return { + columns: [], + rows: [{ index: 'config', health: 'green', 'store.size': '12mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any + }) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_id: string, entity: string) => { + if (entity === 'config') { + return { + columns: [ + { name: 'config', dataType: 'object', nullable: '-' }, + { name: 'config.theme', dataType: 'keyword', nullable: '-' }, + ], + indexes: [], + } as any + } + return { + columns: [], + indexes: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + + const fieldControl = wrapper.get('[data-testid="elastic-dsl-filter-field"]') + expect(fieldControl.element.tagName).toBe('BUTTON') + + await fieldControl.trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + }) + + it('uses request target path instead of selected index for elastic filter field options', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue(['futrixdata-demo-1']) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_id: string, entity: string) => { + if (entity === 'futrixdata-demo-1') { + return { + columns: [{ name: 'wrong_field', dataType: 'keyword' }], + indexes: [], + } as any + } + return { + columns: [], + indexes: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /other-index/_search\n{}') + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + + const fieldControl = wrapper.get('[data-testid="elastic-dsl-filter-field"]') + expect(fieldControl.element.tagName).toBe('INPUT') + }) + + it('loads mappings for the typed elastic request target before opening the DSL field picker', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement || '').includes('/_cat/indices?format=json&h=index,health,store.size')) { + return { + columns: [], + rows: [ + { index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }, + { index: 'config', health: 'green', 'store.size': '8mb' }, + ], + rowCount: 2, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any + }) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_id: string, entity: string) => { + if (entity === 'futrixdata-demo-1') { + return { + columns: [{ name: 'wrong_field', dataType: 'keyword' }], + indexes: [], + } as any + } + if (entity === 'config') { + return { + columns: [ + { name: 'config', dataType: 'object' }, + { name: 'config.theme', dataType: 'keyword' }, + ], + indexes: [], + } as any + } + return { + columns: [], + indexes: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /config/_search\n{}') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + + const fieldControl = wrapper.get('[data-testid="elastic-dsl-filter-field"]') + expect(fieldControl.element.tagName).toBe('BUTTON') + + await fieldControl.trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + }) + + it('limits typed elastic field picker options to checked mappings for the target index', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement || '').includes('/_cat/indices?format=json&h=index,health,store.size')) { + return { + columns: [], + rows: [{ index: 'config', health: 'green', 'store.size': '8mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'config', dataType: 'object' }, + { name: 'config.theme', dataType: 'keyword' }, + ], + indexes: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + store.elasticsearchFieldSelections['config'] = ['config.theme'] + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /config/_search\n{}') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + expect(wrapper.find('[data-testid="elastic-dsl-field-option-config"]').exists()).toBe(false) + }) + + it('retries loading typed elastic request target mappings after an earlier describe failure', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement || '').includes('/_cat/indices?format=json&h=index,health,store.size')) { + return { + columns: [], + rows: [ + { index: 'futrixdata-demo-1', health: 'green', 'store.size': '12mb' }, + { index: 'config', health: 'green', 'store.size': '8mb' }, + ], + rowCount: 2, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any + }) + + let configDescribeAttempts = 0 + const describeSpy = vi.spyOn(api, 'describeEntity').mockImplementation(async (_id: string, entity: string) => { + if (entity === 'futrixdata-demo-1') { + return { + columns: [{ name: 'wrong_field', dataType: 'keyword' }], + indexes: [], + } as any + } + if (entity === 'config') { + configDescribeAttempts += 1 + if (configDescribeAttempts === 1) { + throw new Error('describe failed') + } + return { + columns: [ + { name: 'config', dataType: 'object' }, + { name: 'config.theme', dataType: 'keyword' }, + ], + indexes: [], + } as any + } + return { + columns: [], + indexes: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /config/_search\n{}') + await flushPromises() + await flushPromises() + + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await flushPromises() + await flushPromises() + + await statementInput.setValue('GET /config/_search\n{}') + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + + const fieldControl = wrapper.get('[data-testid="elastic-dsl-filter-field"]') + expect(fieldControl.element.tagName).toBe('BUTTON') + + await fieldControl.trigger('click') + await flushPromises() + + expect(describeSpy).toHaveBeenCalledWith('ds_mysql', 'config', '') + expect(configDescribeAttempts).toBe(2) + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + }) + + it('hides elastic system version fields from the DSL field picker', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement || '').includes('/_cat/indices?format=json&h=index,health,store.size')) { + return { + columns: [], + rows: [{ index: 'config', health: 'green', 'store.size': '8mb' }], + rowCount: 1, + elapsedMs: 12, + } as any + } + return { + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any + }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [ + { name: 'config.theme', dataType: 'keyword' }, + { name: 'field_version.build', dataType: 'keyword' }, + { name: 'filed_version.shadow', dataType: 'keyword' }, + ], + indexes: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + + const fieldControl = wrapper.get('[data-testid="elastic-dsl-filter-field"]') + await fieldControl.trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-config.theme"]').text()).toContain('config.theme') + expect(wrapper.find('[data-testid="elastic-dsl-field-option-field_version.build"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="elastic-dsl-field-option-filed_version.shadow"]').exists()).toBe(false) + }) + + it('keeps case-distinct elastic mappings as separate filter picker options', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue(['futrixdata-demo-1']) + vi.spyOn(api, 'describeEntity').mockImplementation(async (_id: string, entity: string) => { + if (entity === 'futrixdata-demo-1') { + return { + columns: [ + { name: 'UserID', dataType: 'keyword' }, + { name: 'userid', dataType: 'keyword' }, + ], + indexes: [], + } as any + } + return { + columns: [], + indexes: [], + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await flushPromises() + + await wrapper.get('[data-testid="elastic-dsl-add-filter"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="elastic-dsl-filter-field"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="elastic-dsl-field-option-UserID"]').text()).toContain('UserID') + expect(wrapper.get('[data-testid="elastic-dsl-field-option-userid"]').text()).toContain('userid') + }) + + it('treats slashless elastic search statements as workspace requests', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'futrixdata-demo-2', _source: { title: 'Mock doc A', score: 1.0 } }, + ], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET futrixdata-demo-2/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('.result-table').exists()).toBe(false) + }) + + it('keeps parity status header visible when elastic workspace execution fails', async () => { + vi.spyOn(api, 'executeStatement').mockRejectedValue(new Error('elastic failed')) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('.result-header-sql-editor').exists()).toBe(true) + expect(wrapper.find('.result-header-sql-editor').text()).toContain('elastic failed') + }) + + it('clears cached elastic deep-pagination state when a rerun fails after a successful search', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation(async (_id: string, statement: string) => { + if (String(statement).includes('"broken"')) { + throw new Error('elastic failed') + } + return { + columns: [], + rows: Array.from({ length: 50 }, (_, idx) => ({ + _id: String(idx + 1), + _index: 'futrixdata-demo-2', + _source: { title: `Mock doc ${idx + 1}` }, + })), + rowCount: 100000, + elapsedMs: 12, + } as any + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-2' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-2/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-result-window-note"]').exists()).toBe(false) + expect(wrapper.get('[data-testid="elastic-page-2000"]').text()).toBe('2000') + + await statementInput.setValue('GET /futrixdata-demo-2/_search\n{"query":{"broken":}}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.find('[data-testid="elastic-result-window-note"]').exists()).toBe(false) + expect(workspace.text()).not.toContain('100,000 hits') + expect(workspace.find('[data-testid="elastic-page-2000"]').exists()).toBe(false) + expect(wrapper.find('.result-header-sql-editor').text()).toContain('elastic failed') + }) + + it('exports elastic workspace rows as JSON file', async () => { + const exportSpy = vi.spyOn(api, 'exportQueryResult').mockResolvedValue('/tmp/export.json' as any) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'demo', _source: { title: 'Mock doc A', score: 1.0 } }, + { _id: '2', _index: 'demo', _source: { title: 'Mock doc B', score: 0.9 } }, + ], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + await wrapper.get('[data-testid="elastic-export-all"]').trigger('click') + + expect(exportSpy).toHaveBeenCalledTimes(1) + const [fileName, content] = exportSpy.mock.calls[0] || [] + expect(String(fileName || '')).toMatch(/^elasticsearch-result-.*\.json$/) + const exportedRows = JSON.parse(String(content || '[]')) + expect(exportedRows).toHaveLength(2) + expect(exportedRows[1]).toMatchObject({ + _id: '2', + _index: 'demo', + _source: { title: 'Mock doc B', score: 0.9 }, + }) + }) + + it('copies the full elastic cell raw value from the context menu', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { + _id: '1', + _index: 'demo', + _source: { + message: '0123456789abcdefghijklmnopqrstuvwxyz-raw-value', + }, + }, + ], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + await wrapper.get('.elastic-result-cell').trigger('contextmenu', { + clientX: 120, + clientY: 80, + }) + await flushPromises() + + await wrapper.get('[data-testid="elastic-cell-copy-raw"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('0123456789abcdefghijklmnopqrstuvwxyz-raw-value') + expect(store.notice.message).toBe(tApp('console.elastic.results.rawValueCopied')) + wrapper.unmount() + }) + + it('keeps elastic workspace visible for empty _search results in parity mode', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-empty/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-results-workspace"]').text()).toContain(tApp('result.zeroDocuments')) + expect(wrapper.find('.result-header-sql-editor').exists()).toBe(false) + }) + + it('keeps mapped elastic columns when an empty _search response returns no fields', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 12, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'title' }, { name: 'status' }], + indexes: [], + details: [], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-empty' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('GET /futrixdata-demo-empty/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.getComponent(ConsoleElasticResultsWorkspace) + expect(workspace.props('visibleFields')).toEqual(['title', 'status']) + expect(wrapper.find('[data-testid="elastic-results-workspace"]').text()).toContain(tApp('result.zeroDocuments')) + }) + + it('seeds mongo parity editor with default statement when no entities are available', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Mongo', type: 'mongodb', host: '', port: 27017 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + expect((statementInput.element as HTMLTextAreaElement).value).toContain('find().limit(50)') + expect(wrapper.find('.editor-toolbar-sql-editor .toolbar-status').text()).toContain('NO TARGET') + expect(wrapper.find('.result-header-sql-editor p').text()).toContain('Select target then Execute') + expect(wrapper.find('.empty-tip-sql-editor').text()).toContain('Select target then Execute') + expect((wrapper.get('.editor-toolbar-sql-editor .execute-btn').element as HTMLButtonElement).disabled).toBe(true) + expect((wrapper.get('.editor-toolbar-sql-editor .explain-btn').element as HTMLButtonElement).disabled).toBe(true) + }) + + it('replaces mongo placeholder with selected target statement and enables execute', async () => { + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'describeEntity').mockRejectedValue(new Error('describe failed')) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Mongo', type: 'mongodb', host: '', port: 27017 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('db["collection"].find().limit(50);') + store.selectedEntity = 'users' + await flushPromises() + + expect((statementInput.element as HTMLTextAreaElement).value).toContain('db.users.find(') + expect((statementInput.element as HTMLTextAreaElement).value).toContain('limit: 50') + expect((wrapper.get('.editor-toolbar-sql-editor .execute-btn').element as HTMLButtonElement).disabled).toBe(false) + expect((wrapper.get('.editor-toolbar-sql-editor .explain-btn').element as HTMLButtonElement).disabled).toBe(false) + }) + + it('keeps literal whitespace when beautifying mongo statement in parity mode', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Mongo', type: 'mongodb', host: '', port: 27017 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('db["users"].find({ "name": "New York" })') + await wrapper.get('.editor-toolbar-sql-editor .beautiful-btn').trigger('click') + await flushPromises() + + expect((statementInput.element as HTMLTextAreaElement).value).toContain('"New York"') + }) + + it('disables beautify button for dynamodb parity mode', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb', + name: 'DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + options: { region: 'us-east-1' }, + } as any, + ] + + await router.push({ name: 'console', params: { id: 'ds_ddb' } }) + await router.isReady() + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + await statementInput.setValue('select * from "parity_users" where user_id=\'PK#...\'') + await flushPromises() + + const beautifyBtn = wrapper.get('.editor-toolbar-sql-editor .beautiful-btn') + expect((beautifyBtn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('does not render legacy parity textarea and does not force focus to statement-input', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + expect(wrapper.find('#statement-input').exists()).toBe(false) + expect(document.querySelector('#statement-input')).toBeNull() + const active = document.activeElement as HTMLElement | null + expect(active?.id).not.toBe('statement-input') + }) + + it('supports elastic stitch cards and list/raw view toggling in parity mode', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: '1', _index: 'demo', _source: { title: 'Mock doc A', message: 'Mock doc A' } }, + { _id: '2', _index: 'demo', _source: { category: 'analytics', message: 'analytics' } }, + ], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="elastic-results-workspace"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="elastic-results-workspace"]').text()).toContain('analytics') + expect(wrapper.find('[data-testid="elastic-results-workspace"] .elastic-results-footer-range').text()).toBe( + tApp('console.elastic.results.showingRange', { + from: 1, + to: 2, + total: '2', + }), + ) + + await wrapper.get('[data-testid="elastic-view-raw"]').trigger('click') + await flushPromises() + expect(wrapper.find('[data-testid="elastic-results-workspace"]').text()).toContain('analytics') + }) + + it('deep-pages elastic single-index searches with pit and search_after when jumping past the result window', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockImplementation( + createElasticDeepPaginationExecuteMock(), + ) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'demo' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('GET /demo/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.find('[data-testid="elastic-page-1"]').exists()).toBe(true) + expect(workspace.find('[data-testid="elastic-page-2000"]').exists()).toBe(true) + expect(workspace.find('[data-testid="elastic-result-window-note"]').exists()).toBe(false) + + await workspace.get('[data-testid="elastic-page-2000"]').trigger('click') + await flushPromises() + + expect(executeSpy.mock.calls.length).toBeGreaterThanOrEqual(2) + const executedStatements = executeSpy.mock.calls.map((call) => String(call[1] ?? '')) + const lastStatement = executedStatements.at(-1) ?? '' + expect(executedStatements.some((statement) => statement.includes('/_pit'))).toBe(true) + expect(executedStatements.some((statement) => statement.includes('search_after'))).toBe(true) + expect(executedStatements.some((statement) => statement.includes('from=99950'))).toBe(false) + expect(lastStatement).toContain('search_after') + expect(workspace.text()).toContain('Showing 99951-100000 of 100,000 hits') + }) + + it('preserves explicit elastic sort clauses while deep-paging', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockImplementation( + createElasticDeepPaginationExecuteMock({ sortField: 'created_at' }), + ) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'demo' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue( + 'POST /demo/_search\n{\n "sort": [{ "created_at": "desc" }]\n}', + ) + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + await workspace.get('[data-testid="elastic-page-2000"]').trigger('click') + await flushPromises() + + const executedStatements = executeSpy.mock.calls.map((call) => String(call[1] ?? '')) + const deepStatements = executedStatements.filter((statement) => statement.includes('search_after')) + + expect(deepStatements.length).toBeGreaterThan(0) + expect(deepStatements.every((statement) => statement.includes('"created_at":"desc"'))).toBe(true) + }) + + it('keeps elastic deep-pagination page size stable when the last page is short', async () => { + vi.spyOn(api, 'executeStatement').mockImplementation( + createElasticDeepPaginationExecuteMock({ total: 100025, pageSize: 50 }), + ) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'demo' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('GET /demo/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const workspace = wrapper.get('[data-testid="elastic-results-workspace"]') + expect(workspace.find('[data-testid="elastic-page-2001"]').exists()).toBe(true) + + await workspace.get('[data-testid="elastic-page-2001"]').trigger('click') + await flushPromises() + + expect(workspace.text()).toContain('Showing 100001-100025 of 100,025 hits') + expect(workspace.find('[data-testid="elastic-page-2001"]').exists()).toBe(true) + expect(workspace.find('[data-testid="elastic-page-4001"]').exists()).toBe(false) + }) + + it('keeps elastic rows independently expandable when _id repeats across indices', async () => { + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [ + { _id: 'same-id', _index: 'index-a', _source: { title: 'Alpha doc' } }, + { _id: 'same-id', _index: 'index-b', _source: { title: 'Beta doc' } }, + ], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + store.selectedEntity = 'futrixdata-demo-1' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('GET /futrixdata-demo-1/_search\n{}') + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + + const rows = wrapper.findAll('.elastic-results-row') + expect(rows).toHaveLength(2) + await wrapper.get('[data-testid="elastic-row-toggle-0"]').trigger('click') + await flushPromises() + + const details = wrapper.findAll('.elastic-results-row-detail') + expect(details).toHaveLength(1) + expect(details[0]!.text()).toContain('Alpha doc') + expect(details[0]!.text()).not.toContain('Beta doc') + }) + + it('omits the virtual # column for sql-editor parity table results', async () => { + // The `#` row-index column was removed — it's not a real datasource + // column and competed with the row-delete action for the leftmost slot. + vi.spyOn(api, 'listEntities').mockResolvedValue(['table_0001']) + vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['id', 'name'], + rows: [ + { id: 1, name: 'row_1' }, + { id: 2, name: 'row_2' }, + ], + rowCount: 2, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + await getStatementEditorInput(wrapper).setValue('SELECT * FROM table_0001 LIMIT 2;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + const headers = wrapper.findAll('.result-table thead th').map((node) => node.text().trim()) + expect(headers).not.toContain('#') + expect(headers).toEqual(expect.arrayContaining(['id', 'name'])) + expect(wrapper.find('.virtual-table-container--external').exists()).toBe(true) + }) + + it('keeps multi-result tabs visible when elastic workspace tab is active', async () => { + vi.spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: [], + rows: [ + { _id: '1', _index: 'futrixdata-demo-1', _source: { title: 'Mock doc A' } }, + ], + rowCount: 1, + elapsedMs: 10, + } as any) + .mockResolvedValueOnce({ + columns: ['index', 'docs.count'], + rows: [{ index: 'futrixdata-demo-1', 'docs.count': 2 }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'Elastic', type: 'elasticsearch', host: '', port: 9200 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const multiStatement = 'GET /futrixdata-demo-1/_search\n{}\n;\nGET /_cat/indices?format=json' + await statementInput.setValue(multiStatement) + const inputEl = statementInput.element as HTMLTextAreaElement + inputEl.setSelectionRange(0, multiStatement.length) + await statementInput.trigger('keyup') + + await wrapper.get('[data-testid="elastic-dsl-run-search"]').trigger('click') + await flushPromises() + await flushPromises() + + const tabs = wrapper.findAll('.result-tab') + expect(wrapper.find('.result-tabs').exists()).toBe(true) + expect(tabs).toHaveLength(2) + + await tabs[0].trigger('click') + await flushPromises() + + expect(wrapper.find('.result-tabs').exists()).toBe(true) + expect(wrapper.findAll('.result-tab')).toHaveLength(2) + + await tabs[1].trigger('click') + await flushPromises() + + expect(wrapper.find('.result-tabs').exists()).toBe(true) + expect(wrapper.findAll('.result-tab')).toHaveLength(2) + }) + + it('shows switchable result tabs for multi-statement execute in parity mode', async () => { + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: ['id'], + rows: [{ id: 1 }], + rowCount: 1, + elapsedMs: 10, + } as any) + .mockResolvedValueOnce({ + columns: ['id'], + rows: [{ id: 2 }], + rowCount: 1, + elapsedMs: 21, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const multiStatement = 'SELECT 1 AS id;\nSELECT 2 AS id;' + await statementInput.setValue(multiStatement) + const inputEl = statementInput.element as HTMLTextAreaElement + inputEl.setSelectionRange(0, multiStatement.length) + await statementInput.trigger('keyup') + + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(wrapper.find('.result-tabs').exists()).toBe(true) + expect(wrapper.findAll('.result-tab')).toHaveLength(2) + expect(wrapper.get('.result-header-sql-editor h2').text()).toBe(tApp('console.resultsPanel.title')) + expect(wrapper.find('.result-tabs-clear').exists()).toBe(false) + expect(wrapper.find('[data-testid="result-filter-trigger"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="result-filter-search"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="result-filter-export"]').exists()).toBe(true) + expect(wrapper.get('#result-meta').text()).toContain('10ms') + + await wrapper.findAll('.result-tab')[1].trigger('click') + await flushPromises() + + expect(wrapper.get('#result-meta').text()).toContain('21ms') + expect(wrapper.get('.result-header-sql-editor h2').text()).toBe(tApp('console.resultsPanel.title')) + expect(wrapper.find('[data-testid="result-filter-trigger"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="result-filter-search"]').exists()).toBe(true) + expect(wrapper.find('.result-tabs-clear').exists()).toBe(false) + }) + + it('keeps first mongo result snapshot after switching tabs in multi-statement execute', async () => { + vi.spyOn(api, 'appendHistory').mockResolvedValue({} as any) + const shared = { + columns: [], + rows: [{ _id: 'first_doc', status: 'first' }], + rowCount: 1, + elapsedMs: 11, + } as any + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockImplementationOnce(async () => shared) + .mockImplementationOnce(async () => { + shared.rows = [{ _id: 'second_doc', status: 'second' }] + shared.elapsedMs = 22 + return shared + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mongo', name: 'Mongo', type: 'mongodb', host: '', port: 27017, database: 'appdb' } as any, + ] + + await router.push({ name: 'console', params: { id: 'ds_mongo' } }) + await router.isReady() + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const multiStatement = 'db.users.find({ status: "first" }).limit(1);\ndb.users.find({ status: "second" }).limit(1);' + await statementInput.setValue(multiStatement) + + await wrapper.get('.editor-toolbar-sql-editor .execute-all-btn').trigger('click') + await flushPromises() + await flushPromises() + await flushPromises() + for (let i = 0; i < 8 && executeSpy.mock.calls.length < 2; i += 1) { + await flushPromises() + } + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(wrapper.findAll('.result-tab')).toHaveLength(2) + expect(wrapper.get('#result-meta').text()).toContain('11ms') + expect(wrapper.text()).toContain('first_doc') + + await wrapper.findAll('.result-tab')[1].trigger('click') + await flushPromises() + + expect(wrapper.get('#result-meta').text()).toContain('22ms') + expect(wrapper.text()).toContain('second_doc') + + await wrapper.findAll('.result-tab')[0].trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + expect(wrapper.get('#result-meta').text()).toContain('11ms') + expect(wrapper.text()).toContain('first_doc') + }) + + it('uses generic result label for empty mongo result in multi-statement execute', async () => { + const executeSpy = vi + .spyOn(api, 'executeStatement') + .mockResolvedValueOnce({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 8, + } as any) + .mockResolvedValueOnce({ + columns: [], + rows: [{ _id: 'doc_1', status: 'ok' }], + rowCount: 1, + elapsedMs: 16, + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mongo', name: 'Mongo', type: 'mongodb', host: '', port: 27017, database: 'appdb' } as any, + ] + + await router.push({ name: 'console', params: { id: 'ds_mongo' } }) + await router.isReady() + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const multiStatement = + 'db["empty_collection"].find({}, { projection: { _id: 1 } }).limit(1);\ndb.users.find({}, { projection: { _id: 1 } }).limit(1);' + await statementInput.setValue(multiStatement) + + await wrapper.get('.editor-toolbar-sql-editor .execute-all-btn').trigger('click') + await flushPromises() + await flushPromises() + await flushPromises() + + expect(executeSpy).toHaveBeenCalledTimes(2) + const tabs = wrapper.findAll('.result-tab') + expect(tabs).toHaveLength(2) + expect(tabs[0].text()).toContain(tApp('console.results.resultWithIndex', { index: 1 })) + expect(tabs[0].text()).not.toContain('db[') + expect(wrapper.get('.result-header-sql-editor h2').text()).toBe(tApp('console.resultsPanel.title')) + expect(wrapper.find('.result').text()).toContain(tApp('result.noDocumentsMatched')) + }) + + it('shows switchable result tabs for multi-statement explain in parity mode', async () => { + const explainSpy = vi + .spyOn(api, 'explainStatement') + .mockResolvedValueOnce({ + usesIndex: true, + detail: [{ id: 1 }], + } as any) + .mockResolvedValueOnce({ + usesIndex: false, + detail: [{ id: 2 }], + } as any) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const statementInput = getStatementEditorInput(wrapper) + const multiStatement = 'SELECT 1 AS id;\nSELECT 2 AS id;' + await statementInput.setValue(multiStatement) + const inputEl = statementInput.element as HTMLTextAreaElement + inputEl.setSelectionRange(0, multiStatement.length) + await statementInput.trigger('keyup') + + await wrapper.get('.editor-toolbar-sql-editor .explain-btn').trigger('click') + await flushPromises() + + expect(explainSpy).toHaveBeenCalledTimes(2) + expect(wrapper.find('.result-tabs').exists()).toBe(true) + expect(wrapper.findAll('.result-tab')).toHaveLength(2) + expect(wrapper.get('#result-meta').text()).toBe(tApp('status.explainUsesIndex')) + + await wrapper.findAll('.result-tab')[1].trigger('click') + await flushPromises() + + expect(wrapper.get('#result-meta').text()).toBe(tApp('status.explainNoIndex')) + expect(wrapper.get('.result-header-sql-editor h2').text()).toBe(tApp('console.resultsPanel.title')) + }) +}) diff --git a/frontend/src/__tests__/console-sql-editor-parity-utils.test.ts b/frontend/src/__tests__/console-sql-editor-parity-utils.test.ts new file mode 100644 index 0000000..9562904 --- /dev/null +++ b/frontend/src/__tests__/console-sql-editor-parity-utils.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + formatParityEngineName, + formatParityTabTitle, + isSqlEditorParityDatasourceType, +} from '@/views/console/utils/sqlEditorParity' + +describe('sql-editor parity utilities', () => { + it('formats parity tab titles to sql-editor style labels', () => { + expect(formatParityTabTitle('', 0)).toBe('Query 1') + expect(formatParityTabTitle('1', 0)).toBe('Query 1') + expect(formatParityTabTitle('query 2', 1)).toBe('Query 2') + expect(formatParityTabTitle('query 2.sql', 1)).toBe('Query 2') + expect(formatParityTabTitle('report_v2', 1)).toBe('report_v2') + expect(formatParityTabTitle('report_v2.sql', 1)).toBe('report_v2.sql') + }) + + it('formats datasource engine names with sql-editor uppercase labels', () => { + expect(formatParityEngineName('mysql')).toBe('MYSQL 8.0') + expect(formatParityEngineName('postgresql')).toBe('POSTGRESQL 16') + expect(formatParityEngineName('mongodb')).toBe('MONGODB 7.0') + expect(formatParityEngineName('elasticsearch')).toBe('ELASTICSEARCH 8') + expect(formatParityEngineName('dynamodb')).toBe('DYNAMODB') + expect(formatParityEngineName('redis')).toBe('REDIS') + expect(formatParityEngineName('')).toBe('ENGINE') + }) + + it('detects sql-editor parity datasource types', () => { + expect(isSqlEditorParityDatasourceType('mysql')).toBe(true) + expect(isSqlEditorParityDatasourceType('postgresql')).toBe(true) + expect(isSqlEditorParityDatasourceType('d1')).toBe(true) + expect(isSqlEditorParityDatasourceType('mongodb')).toBe(true) + expect(isSqlEditorParityDatasourceType('elasticsearch')).toBe(true) + expect(isSqlEditorParityDatasourceType('dynamodb')).toBe(true) + expect(isSqlEditorParityDatasourceType('redis')).toBe(false) + expect(isSqlEditorParityDatasourceType('')).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/console-sql-editor-theme-parity.test.ts b/frontend/src/__tests__/console-sql-editor-theme-parity.test.ts new file mode 100644 index 0000000..e601b06 --- /dev/null +++ b/frontend/src/__tests__/console-sql-editor-theme-parity.test.ts @@ -0,0 +1,45 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('console sql-editor parity theme colors', () => { + it('maps light and dark palette to shared app (redis) theme tokens', () => { + const css = loadStyleCss() + + expect(css).toContain('--sql-editor-bg: var(--color-background-light)') + expect(css).toContain('--sql-editor-surface: var(--color-surface-light)') + expect(css).toContain('--sql-editor-border: var(--color-border-light)') + expect(css).toContain('--sql-editor-text: var(--color-text-main-light)') + expect(css).toContain('--sql-editor-muted: var(--color-text-muted-light)') + + expect(css).toContain('.dark .console-shell.sql-editor-parity') + expect(css).toContain('--sql-editor-bg: var(--color-background-dark)') + expect(css).toContain('--sql-editor-surface: var(--color-surface-dark)') + expect(css).toContain('--sql-editor-border: var(--color-border-dark)') + expect(css).toContain('--sql-editor-text: var(--color-text-main-dark)') + expect(css).toContain('--sql-editor-muted: var(--color-text-muted-dark)') + }) + + it('uses datasource semantic colors for sql/mongo/es keywords', () => { + const css = loadStyleCss() + + expect(css).toContain('statement-token-keyword-sql') + expect(css).toContain('--sql-editor-token-sql: var(--ds-mysql)') + expect(css).toContain('color: var(--sql-editor-token-sql)') + + expect(css).toContain('statement-token-keyword-mongo') + expect(css).toContain('--sql-editor-token-mongo: var(--ds-mongodb)') + expect(css).toContain('color: var(--sql-editor-token-mongo)') + + expect(css).toContain('statement-token-keyword-es') + expect(css).toContain('--sql-editor-token-es: var(--ds-elasticsearch)') + expect(css).toContain('color: var(--sql-editor-token-es)') + }) +}) diff --git a/frontend/src/__tests__/console-statement-context-menu.test.ts b/frontend/src/__tests__/console-statement-context-menu.test.ts new file mode 100644 index 0000000..4e2896d --- /dev/null +++ b/frontend/src/__tests__/console-statement-context-menu.test.ts @@ -0,0 +1,135 @@ +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { afterEach, describe, expect, it } from 'vitest' + +import ConsoleStatementContextMenu from '@/views/console/components/ConsoleStatementContextMenu.vue' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const baseProps = { + visible: true, + x: 120, + y: 80, + hasSelection: true, + hasContent: true, + canExecute: true, +} + +describe('ConsoleStatementContextMenu custom prompt composer', () => { + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + afterEach(() => { + resetAppI18nForTest() + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + }) + + it('keeps generic AI shortcuts by default and sends custom prompt on click', async () => { + setAppLocale('en') + + const wrapper = mount(ConsoleStatementContextMenu, { + props: baseProps, + attachTo: document.body, + }) + + await wrapper.get('.relative.group').trigger('mouseenter') + await nextTick() + + expect(wrapper.get('[data-testid="statement-context-ask-ai-explain-logic"]').text()).toContain( + tApp('context.explainLogic'), + ) + expect(wrapper.get('[data-testid="statement-context-ask-ai-optimize-performance"]').text()).toContain( + tApp('context.optimizePerformance'), + ) + expect(wrapper.get('[data-testid="statement-context-ask-ai-debug-error"]').text()).toContain( + tApp('context.debugError'), + ) + expect(wrapper.find('[data-testid="statement-context-ask-ai-redis-help"]').exists()).toBe(false) + + const input = wrapper.get('[data-testid="statement-context-ask-ai-custom"]') + expect(input.classes()).toContain('ai-composer-input-area') + + await input.setValue('Explain this SQL') + await wrapper.get('[aria-label="Send message"]').trigger('click') + + expect(wrapper.emitted('ask-ai')?.[0]).toEqual(['Explain this SQL']) + expect(wrapper.emitted('close')).toBeTruthy() + + wrapper.unmount() + }) + + it('keeps only Redis command help shortcut in redis-help preset', async () => { + setAppLocale('en') + + const wrapper = mount(ConsoleStatementContextMenu, { + props: { + ...baseProps, + aiShortcutPreset: 'redis-help-only', + }, + attachTo: document.body, + }) + + await wrapper.get('.relative.group').trigger('mouseenter') + await nextTick() + + expect(wrapper.get('[data-testid="statement-context-ask-ai-redis-help"]').text()).toContain( + tApp('context.redisCommandHelp'), + ) + expect(wrapper.find('[data-testid="statement-context-ask-ai-explain-logic"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="statement-context-ask-ai-optimize-performance"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="statement-context-ask-ai-debug-error"]').exists()).toBe(false) + + wrapper.unmount() + }) + + it('repositions AI tooltip near viewport edges to avoid clipping', async () => { + setAppLocale('en') + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 420 }) + + const wrapper = mount(ConsoleStatementContextMenu, { + props: { + ...baseProps, + x: 760, + y: 330, + }, + attachTo: document.body, + }) + + const menuEl = wrapper.get('[data-testid="statement-context-menu"]').element as HTMLElement + Object.defineProperty(menuEl, 'offsetWidth', { configurable: true, value: 256 }) + Object.defineProperty(menuEl, 'offsetHeight', { configurable: true, value: 180 }) + Object.defineProperty(menuEl, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: 760, + y: 330, + left: 760, + top: 330, + width: 256, + height: 180, + right: 1016, + bottom: 510, + toJSON: () => null, + }) as DOMRect, + }) + + await wrapper.get('.relative.group').trigger('mouseenter') + await nextTick() + + const tooltip = wrapper.get('[data-testid="statement-context-ask-ai-tooltip"]').element as HTMLElement + Object.defineProperty(tooltip, 'offsetWidth', { configurable: true, value: 288 }) + Object.defineProperty(tooltip, 'offsetHeight', { configurable: true, value: 320 }) + + window.dispatchEvent(new Event('resize')) + await nextTick() + + expect(tooltip.className).toContain('right-full') + expect(tooltip.style.top).toBe('-238px') + expect(tooltip.className).toContain('overflow-x-hidden') + expect(tooltip.className).toContain('ask-ai-tooltip-scrollless') + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/console-statement-highlight.test.ts b/frontend/src/__tests__/console-statement-highlight.test.ts new file mode 100644 index 0000000..f6481d4 --- /dev/null +++ b/frontend/src/__tests__/console-statement-highlight.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { buildStatementHighlightHtml } from '@/views/console/utils/statementHighlight' + +describe('console statement syntax highlight', () => { + it('highlights SQL keywords and numbers with bold token classes', () => { + const html = buildStatementHighlightHtml('SELECT * FROM users LIMIT 50;', 'mysql') + expect(html).toContain('statement-token-keyword-sql') + expect(html).toContain('>SELECT<') + expect(html).toContain('statement-token-number') + expect(html).toContain('>50<') + }) + + it('highlights Mongo operators and methods', () => { + const html = buildStatementHighlightHtml('db.users.updateOne({}, {$set: {status: "active"}})', 'mongodb') + expect(html).toContain('statement-token-keyword-mongo') + expect(html).toContain('>db<') + expect(html).toContain('statement-token-operator') + expect(html).toContain('>$set<') + }) + + it('highlights Elasticsearch request verbs', () => { + const html = buildStatementHighlightHtml('GET /orders/_search', 'elasticsearch') + expect(html).toContain('statement-token-keyword-es') + expect(html).toContain('>GET<') + }) +}) diff --git a/frontend/src/__tests__/console-statement-tabs.test.ts b/frontend/src/__tests__/console-statement-tabs.test.ts new file mode 100644 index 0000000..047348f --- /dev/null +++ b/frontend/src/__tests__/console-statement-tabs.test.ts @@ -0,0 +1,436 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { getDatasourceTypeIconUrl } from '@/modules/datasource/icons' +import { getConsoleStatementInput } from './helpers/consoleEditor' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_console' }, query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +const makeDatasource = (type: 'mysql' | 'mongodb' | 'redis') => ({ + id: 'ds_console', + name: 'Console', + type, + host: 'localhost', + port: type === 'mongodb' ? 27017 : type === 'redis' ? 6379 : 3306, + username: '', + password: '', + database: type === 'mongodb' ? 'admin' : '', + authSource: '', + options: {}, +}) + +const createDragDataTransfer = () => ({ + dropEffect: 'move', + effectAllowed: 'move', + files: [], + items: [], + types: [], + clearData: vi.fn(), + getData: vi.fn(() => ''), + setData: vi.fn(), + setDragImage: vi.fn(), +}) + +const stubHorizontalRect = (el: Element, left: number, width = 100) => { + Object.defineProperty(el, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: left, + y: 0, + top: 0, + left, + width, + height: 32, + right: left + width, + bottom: 32, + toJSON: () => ({}), + }), + }) +} + +describe('ConsoleView statement tabs', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: [], cursor: '', done: true } as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('preserves drafts when switching statement tabs', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + const add = () => wrapper.find('[data-testid="statement-tab-add"]') + + expect(tabs().length).toBe(1) + + await getConsoleStatementInput(wrapper).setValue('SELECT 1') + + await add().trigger('click') + await flushPromises() + + expect(tabs().length).toBe(2) + await getConsoleStatementInput(wrapper).setValue('SELECT 2') + + await tabs()[0].trigger('click') + await flushPromises() + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 1') + + await tabs()[1].trigger('click') + await flushPromises() + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 2') + }) + + it('renders datasource svg icons in query tabs instead of datasource text badges', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const tab = wrapper.get('[data-testid="statement-tab"]') + const icon = tab.get('[data-testid="statement-tab-datasource-icon"]') + + expect(icon.attributes('src')).toBe(getDatasourceTypeIconUrl('mysql')) + expect(tab.find('.statement-tab-badge').exists()).toBe(false) + expect(tab.text()).toContain('Query 1') + }) + + it('switches between same-datasource tabs without reloading entities and restores prior results', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['value'], + rows: [{ value: 42 }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const listEntitiesPageSpy = vi.mocked(api.listEntitiesPage).mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'bigint', nullable: 'NO', defaultValue: null }], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + details: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + const add = () => wrapper.get('[data-testid="statement-tab-add"]') + + await getConsoleStatementInput(wrapper).setValue('SELECT 42 AS value;') + await wrapper.get('.editor-toolbar-sql-editor .execute-btn').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('42') + expect(listEntitiesPageSpy).toHaveBeenCalledTimes(1) + + await add().trigger('click') + await flushPromises() + + expect(tabs()).toHaveLength(2) + await getConsoleStatementInput(wrapper).setValue('SELECT 7 AS value;') + await flushPromises() + + await tabs()[0]!.trigger('click') + await flushPromises() + + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toContain('SELECT 42 AS value;') + expect(wrapper.text()).toContain('42') + expect(listEntitiesPageSpy).toHaveBeenCalledTimes(1) + expect(executeSpy).toHaveBeenCalledTimes(1) + }) + + it('reorders tabs by drag and drop without reloading entities or losing drafts', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const listEntitiesPageSpy = vi.mocked(api.listEntitiesPage).mockResolvedValue({ + items: ['orders'], + cursor: '', + done: true, + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await flushPromises() + + const tabs = () => wrapper.findAll('[data-testid="statement-tab"]') + const add = () => wrapper.get('[data-testid="statement-tab-add"]') + const labels = () => wrapper.findAll('.statement-tab-label').map((node) => node.text()) + + await getConsoleStatementInput(wrapper).setValue('SELECT 1') + await add().trigger('click') + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT 2') + await add().trigger('click') + await flushPromises() + await getConsoleStatementInput(wrapper).setValue('SELECT 3') + await flushPromises() + + expect(labels()).toEqual(['Query 1', 'Query 2', 'Query 3']) + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 3') + expect(listEntitiesPageSpy).toHaveBeenCalledTimes(1) + + const dragData = createDragDataTransfer() + const currentTabs = tabs() + stubHorizontalRect(currentTabs[0]!.element, 0) + stubHorizontalRect(currentTabs[2]!.element, 220) + + await currentTabs[2]!.trigger('dragstart', { dataTransfer: dragData }) + await currentTabs[0]!.trigger('dragover', { dataTransfer: dragData, clientX: 8 }) + await currentTabs[0]!.trigger('drop', { dataTransfer: dragData, clientX: 8 }) + await currentTabs[2]!.trigger('dragend', { dataTransfer: dragData }) + await flushPromises() + + expect(labels()).toEqual(['Query 3', 'Query 1', 'Query 2']) + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 3') + expect(tabs()[0]!.attributes('aria-selected')).toBe('true') + expect(listEntitiesPageSpy).toHaveBeenCalledTimes(1) + + await new Promise((resolve) => setTimeout(resolve, 0)) + await tabs()[1]!.trigger('click') + await flushPromises() + expect((getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value).toBe('SELECT 1') + }) + + it('executes the selected statement from the context menu', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['value'], + rows: [{ value: 1 }], + rowCount: 1, + elapsedMs: 1, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const editor = getConsoleStatementInput(wrapper) + await editor.setValue('SELECT 1;\nSELECT 2') + + const el = editor.element as HTMLTextAreaElement + const start = el.value.indexOf('SELECT 2') + el.selectionStart = start + el.selectionEnd = start + 'SELECT 2'.length + + await editor.trigger('contextmenu', { clientX: 120, clientY: 80 }) + await flushPromises() + + const menu = wrapper.find('[data-testid="statement-context-menu"]') + expect(menu.exists()).toBe(true) + + await wrapper.find('[data-testid="statement-context-execute"]').trigger('click') + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + expect(executeSpy.mock.calls[0][1]).toBe('SELECT 2') + }) + + it('appends generated table statement below existing text when clicking an entity (mysql)', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['value'], + rows: [{ value: 1 }], + rowCount: 1, + elapsedMs: 1, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql')] + + vi.mocked(api.listEntitiesPage).mockResolvedValue({ items: ['accounts', 'users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [{ name: 'id', dataType: 'int', nullable: 'NO', defaultValue: null }], + indexes: [], + details: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT 1;') + await flushPromises() + + const entity = wrapper.findAll('.entity-item').find((el) => el.text().includes('users')) + expect(entity).toBeTruthy() + await entity!.trigger('click') + await flushPromises() + + const statementValue = (getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value + expect(statementValue).toMatch(/SELECT 1;\nSELECT /) + expect(statementValue).toContain('FROM users') + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(String(executeSpy.mock.calls[0]?.[1] || '')).toContain('FROM users') + }) + + it('appends generated collection statement below existing text when clicking an entity (mongodb)', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: [], + rows: [], + rowCount: 0, + elapsedMs: 1, + }) + + const store = useAppStore() + store.datasources = [makeDatasource('mongodb')] + + vi.mocked(api.listEntities).mockResolvedValue(['orders', 'users']) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('db.orders.find({});') + await flushPromises() + + const entity = wrapper.findAll('.entity-item').find((el) => el.text().includes('users')) + expect(entity).toBeTruthy() + await entity!.trigger('click') + await flushPromises() + + const statementValue = (getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value + expect(statementValue).toMatch(/db\.orders\.find\(\{\}\);\ndb\.users\.find/) + expect(executeSpy).toHaveBeenCalledTimes(1) + expect(String(executeSpy.mock.calls[0]?.[1] || '')).toContain('db.users.find') + }) + + it('appends generated table statement below existing text when clicking an entity (dynamodb)', async () => { + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['pk'], + rows: [{ pk: 'PK#1' }], + rowCount: 1, + elapsedMs: 1, + } as any) + + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + store.datasources[0].type = 'dynamodb' + + vi.mocked(api.listEntitiesPage).mockResolvedValue({ items: ['orders', 'users'], cursor: '', done: true } as any) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [{ label: 'Partition Key', value: 'pk' }], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('SELECT 1') + await flushPromises() + + const entity = wrapper.findAll('.entity-item').find((el) => el.text().includes('users')) + expect(entity).toBeTruthy() + await entity!.trigger('click') + await flushPromises() + + const statementValue = (getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value + expect(statementValue).toContain('SELECT 1') + expect(statementValue).toContain('FROM "users"') + expect(executeSpy).not.toHaveBeenCalled() + }) + + it('appends generated index search statement below existing text when clicking an entity (elasticsearch)', async () => { + const store = useAppStore() + store.datasources = [makeDatasource('mysql') as any] + store.datasources[0].type = 'elasticsearch' + + vi.spyOn(api, 'executeStatement').mockRejectedValue(new Error('skip cat indices')) + vi.mocked(api.listEntities).mockResolvedValue(['clusters', 'users']) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [], + } as any) + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await getConsoleStatementInput(wrapper).setValue('GET /_cluster/health') + await flushPromises() + + const entity = wrapper.findAll('.entity-item').find((el) => el.text().includes('users')) + expect(entity).toBeTruthy() + await entity!.trigger('click') + await flushPromises() + + const statementValue = (getConsoleStatementInput(wrapper).element as HTMLTextAreaElement).value + expect(statementValue).toContain('POST /users/_search') + expect(statementValue).not.toContain('GET /_cluster/health') + }) +}) diff --git a/frontend/src/__tests__/console-table-create-statement.test.ts b/frontend/src/__tests__/console-table-create-statement.test.ts new file mode 100644 index 0000000..b543943 --- /dev/null +++ b/frontend/src/__tests__/console-table-create-statement.test.ts @@ -0,0 +1,64 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_mysql' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('ConsoleView table context menu', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows MySQL CREATE TABLE statement on right click', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: 'localhost', port: 3306 } as any, + ] + + vi.spyOn(api, 'listEntitiesPage').mockResolvedValue({ items: ['users'], cursor: '', done: true } as any) + const executeSpy = vi.spyOn(api, 'executeStatement').mockResolvedValue({ + columns: ['Table', 'Create Table'], + rows: [{ Table: 'users', 'Create Table': 'CREATE TABLE `users` (id int)' }], + rowCount: 1, + elapsedMs: 12, + } as any) + + const wrapper = mount(ConsoleView, { + attachTo: document.body, + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const entityRow = wrapper + .findAll('.entity-entry .entity-item') + .find((node) => node.text().includes('users')) + expect(entityRow, 'expected entity row to render').toBeTruthy() + + await entityRow!.trigger('contextmenu', { clientX: 8, clientY: 8 }) + await flushPromises() + + expect(executeSpy).toHaveBeenCalled() + expect(wrapper.find('[data-testid="create-table-dialog"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="create-table-sql"]').text()).toContain('CREATE TABLE') + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/console-theme-css.test.ts b/frontend/src/__tests__/console-theme-css.test.ts new file mode 100644 index 0000000..a14af77 --- /dev/null +++ b/frontend/src/__tests__/console-theme-css.test.ts @@ -0,0 +1,27 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('console theme CSS', () => { + it('uses theme-aware background for statement editor', () => { + const css = loadStyleCss() + const block = css.match(/\.statement-shell\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('background: var(--input-bg)') + expect(block).not.toContain('background: #fffdf8') + }) + + it('uses theme-aware background for unknown status pill', () => { + const css = loadStyleCss() + const block = css.match(/\.status\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).not.toContain('background: #fff0e3') + }) +}) diff --git a/frontend/src/__tests__/console-usability-css.test.ts b/frontend/src/__tests__/console-usability-css.test.ts new file mode 100644 index 0000000..fc741d9 --- /dev/null +++ b/frontend/src/__tests__/console-usability-css.test.ts @@ -0,0 +1,217 @@ +import path from 'node:path' +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const css = readCssWithImports(path.resolve(__dirname, '..', 'style.css')) +const redisShellSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/components/RedisConsoleShell.vue'), + 'utf8', +) +const entitiesPanelSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/components/ConsoleEntitiesPanel.vue'), + 'utf8', +) +const splitPaneSource = readFileSync( + path.resolve(__dirname, '..', 'views/console/composables/useConsoleSplitPane.ts'), + 'utf8', +) +const sidebarSource = readFileSync( + path.resolve(__dirname, '..', 'core/layout/Sidebar.vue'), + 'utf8', +) +const themeToggleSource = readFileSync( + path.resolve(__dirname, '..', 'components/ThemeToggle.vue'), + 'utf8', +) +const sqlEditorJsonTreeNodeSource = readFileSync( + path.resolve(__dirname, '..', 'components/SqlEditorJsonTreeNode.vue'), + 'utf8', +) + +describe('console usability css', () => { + it('keeps console interaction targets at 32px in datasource and statement workspaces', () => { + const entityToggle = css.match(/\.entity-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const redisTreeToggle = css.match(/\.redis-tree-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const statementTabClose = css.match(/\.statement-tab-close\s*\{[\s\S]*?\}/)?.[0] ?? '' + const toolbarButtons = css.match(/\.editor-toolbar-sql-editor\s+\.toolbar-left\s+button\s*\{[\s\S]*?\}/)?.[0] ?? '' + const analyzeToggle = css.match(/\.editor-toolbar-sql-editor\s+\.analyze-toggle-sql-editor\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityActionButtons = css.match(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.mini,[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.small\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultFilterTrigger = css.match(/\.result-filter-trigger\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultFilterExport = css.match(/\.result-filter-export\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultFilterClear = css.match(/\.result-filter-clear\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultFilterSearch = css.match(/\.result-filter-search\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultFilterPanelActions = css.match(/\.result-filter-panel-actions button\s*\{[\s\S]*?\}/)?.[0] ?? '' + const footerPagerButtons = css.match(/\.result-footer-sql-editor \.pager button\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticDslChip = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-chip\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticAddFilter = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-add-filter-btn\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticLiveToggle = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-live-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticReset = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-reset-btn\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticRun = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-run-btn\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityHead = css.match(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityList = css.match(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.entity-list\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityEntry = css.match(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.entity-entry\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityItem = css.match(/\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.entity-item\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticEntityList = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.entity-list\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticEntityEntry = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.entity-entry\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticEntityItem = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.entity-item\s*\{[\s\S]*?\}/)?.[0] ?? '' + const elasticEntityToggle = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.entity-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const compactElasticMeta = css.match(/@media\s*\(max-width:\s*760px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.es-index-meta\s*\{[\s\S]*?gap:\s*4px[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--entities\s+\.es-store-size\s*\{[\s\S]*?display:\s*none[\s\S]*?\}/)?.[0] ?? '' + const chromaEntityList = css.match(/\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.entity-list\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chromaEntityItem = css.match(/\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.entity-item\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chromaMetaBadge = css.match(/\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.chroma-collection-badge\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chromaMetaPanel = css.match(/\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.chroma-collection-inline\s*\{[\s\S]*?\}/)?.[0] ?? '' + const mediumEntityHead = css.match(/@media\s*\(max-width:\s*1080px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head\s*\{[\s\S]*?flex-direction:\s*column[\s\S]*?\}/)?.[0] ?? '' + const mediumEntityActions = css.match(/@media\s*\(max-width:\s*1080px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head-actions\s*\{[\s\S]*?overflow-x:\s*auto[\s\S]*?\}/)?.[0] ?? '' + const narrowEntityActions = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head-actions\s*\{[\s\S]*?flex-wrap:\s*nowrap[\s\S]*?overflow-x:\s*auto[\s\S]*?\}/)?.[0] ?? '' + const narrowEntityButtons = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.mini,\s*\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.small\s*\{[\s\S]*?flex:\s*0\s+0\s+auto[\s\S]*?white-space:\s*nowrap[\s\S]*?\}/)?.[0] ?? '' + const compactEditorResults = css.match(/@media\s*\(max-width:\s*760px\)\s*\{[\s\S]*?\.console-panel--statement\.sql-editor-parity\s+\.console-editor-results-shell\.sql-editor-parity\s*\{[\s\S]*?min-height:\s*520px[\s\S]*?minmax\(240px,\s*45%\)[\s\S]*?minmax\(180px,\s*1fr\)[\s\S]*?\}/)?.[0] ?? '' + const compactShellNav = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.app-nav-panel\s*\{[\s\S]*?align-items:\s*center[\s\S]*?\.app-nav-link\s*\{[\s\S]*?width:\s*44px[\s\S]*?justify-content:\s*center[\s\S]*?\.app-nav-label\s*\{[\s\S]*?display:\s*none[\s\S]*?\}/)?.[0] ?? '' + + expect(entityToggle).toMatch(/width:\s*32px/i) + expect(entityToggle).toMatch(/height:\s*32px/i) + expect(redisTreeToggle).toMatch(/width:\s*32px/i) + expect(redisTreeToggle).toMatch(/height:\s*32px/i) + expect(statementTabClose).toMatch(/width:\s*32px/i) + expect(statementTabClose).toMatch(/height:\s*32px/i) + expect(toolbarButtons).toMatch(/min-height:\s*32px/i) + expect(analyzeToggle).toMatch(/min-height:\s*32px/i) + expect(entityActionButtons).toMatch(/min-height:\s*32px/i) + expect(resultFilterTrigger).toMatch(/min-height:\s*32px/i) + expect(resultFilterExport).toMatch(/min-height:\s*32px/i) + expect(resultFilterClear).toMatch(/min-height:\s*32px/i) + expect(resultFilterSearch).toMatch(/min-height:\s*32px/i) + expect(resultFilterPanelActions).toMatch(/min-height:\s*(?:32px|var\(--control-height[^)]*\))/i) + expect(footerPagerButtons).toMatch(/min-width:\s*32px/i) + expect(footerPagerButtons).toMatch(/min-height:\s*32px/i) + expect(elasticDslChip).toMatch(/min-height:\s*32px/i) + expect(elasticAddFilter).toMatch(/min-height:\s*32px/i) + expect(elasticAddFilter).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(elasticLiveToggle).toMatch(/min-height:\s*32px/i) + expect(elasticLiveToggle).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(elasticReset).toMatch(/min-height:\s*32px/i) + expect(elasticReset).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(elasticRun).toMatch(/height:\s*32px/i) + expect(elasticRun).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(sqlEditorJsonTreeNodeSource).toMatch(/\.sql-editor-json-line\s*\{[\s\S]*?min-height:\s*32px/i) + expect(sqlEditorJsonTreeNodeSource).toMatch(/\.sql-editor-json-toggle\s*\{[\s\S]*?width:\s*32px/i) + expect(sqlEditorJsonTreeNodeSource).toMatch(/\.sql-editor-json-toggle\s*\{[\s\S]*?height:\s*32px/i) + expect(sqlEditorJsonTreeNodeSource).toMatch(/\.sql-editor-json-toggle-placeholder\s*\{[\s\S]*?width:\s*32px/i) + expect(entityHead).toMatch(/padding:\s*10px\s+42px\s+10px\s+10px/i) + expect(entityList).toMatch(/padding:\s*8px/i) + expect(entityList).toMatch(/gap:\s*0/i) + expect(entityEntry).toMatch(/gap:\s*0/i) + expect(entityItem).toMatch(/padding:\s*0\s+8px/i) + expect(entityItem).toMatch(/min-height:\s*32px/i) + expect(elasticEntityList).toMatch(/gap:\s*0/i) + expect(elasticEntityEntry).toMatch(/gap:\s*0/i) + expect(elasticEntityEntry).toMatch(/content-visibility:\s*auto/i) + expect(elasticEntityEntry).toMatch(/contain-intrinsic-size:\s*auto\s+32px/i) + expect(elasticEntityItem).toMatch(/padding:\s*0\s+8px/i) + expect(elasticEntityItem).toMatch(/min-height:\s*32px/i) + expect(elasticEntityToggle).toMatch(/width:\s*32px/i) + expect(elasticEntityToggle).toMatch(/height:\s*32px/i) + expect(compactElasticMeta).not.toBe('') + expect(chromaEntityList).toMatch(/gap:\s*0/i) + expect(chromaEntityItem).toMatch(/min-height:\s*32px/i) + expect(chromaMetaBadge).toMatch(/min-height:\s*20px/i) + expect(chromaMetaPanel).toMatch(/display:\s*inline-flex/i) + expect(chromaMetaPanel).toMatch(/flex-wrap:\s*wrap/i) + expect(chromaMetaPanel).toMatch(/justify-content:\s*flex-end/i) + expect(entitiesPanelSource).toContain('
{{ db }}
') + expect(entitiesPanelSource).toContain('
{{ item }}
') + expect(mediumEntityHead).not.toBe('') + expect(mediumEntityActions).not.toBe('') + expect(narrowEntityActions).not.toBe('') + expect(narrowEntityButtons).not.toBe('') + expect(compactEditorResults).not.toBe('') + expect(compactShellNav).not.toBe('') + }) + + it('uses a shared icon header and refresh button chrome for entity panes, and keeps redis on the same responsive left-width caps', () => { + const entityHeaderMain = css.match(/\.entity-panel-header-main\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityHeaderCopy = css.match(/\.entity-panel-header-copy\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityHeaderIcon = css.match(/\.entity-panel-header-icon\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityHeaderLabel = css.match(/\.entity-panel-header-label\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityHeaderMeta = css.match(/\.entity-panel-header-meta\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityRefreshButton = css.match(/\.entity-panel-refresh-button\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(entityHeaderMain).toMatch(/display:\s*flex/i) + expect(entityHeaderMain).toMatch(/align-items:\s*center/i) + expect(entityHeaderMain).toMatch(/min-width:\s*0/i) + expect(entityHeaderCopy).toMatch(/flex-direction:\s*column/i) + expect(entityHeaderCopy).toMatch(/gap:\s*2px/i) + expect(entityHeaderIcon).toMatch(/width:\s*18px/i) + expect(entityHeaderIcon).toMatch(/height:\s*18px/i) + expect(entityHeaderLabel).toMatch(/white-space:\s*nowrap/i) + expect(entityHeaderLabel).toMatch(/overflow:\s*hidden/i) + expect(entityHeaderMeta).toMatch(/font-size:\s*11px/i) + expect(entityHeaderMeta).toMatch(/font-weight:\s*400/i) + expect(entityHeaderMeta).toMatch(/white-space:\s*nowrap/i) + expect(entityRefreshButton).toMatch(/width:\s*32px/i) + expect(entityRefreshButton).toMatch(/height:\s*32px/i) + expect(entityRefreshButton).toMatch(/display:\s*inline-flex/i) + expect(entityRefreshButton).toMatch(/justify-content:\s*center/i) + expect(splitPaneSource).toContain('export const DEFAULT_CONSOLE_SPLIT = 250') + expect(redisShellSource).toContain('const keysPanelWidth = ref(DEFAULT_CONSOLE_SPLIT)') + expect(redisShellSource).toContain('const effectiveKeysPanelWidth = computed(() => {') + expect(redisShellSource).toContain('if (viewportWidth.value <= 840) return Math.max(136, Math.min(current, 150))') + expect(redisShellSource).toContain('if (viewportWidth.value <= 1080) return Math.max(168, Math.min(current, 200))') + expect(redisShellSource).toContain(':style="{ width: `${effectiveKeysPanelWidth}px` }"') + }) + + it('uses overlay-sized splitter handles in sql parity so resize affordances stay easy to grab', () => { + const shellSplitter = css.match(/\.console-shell\.sql-editor-parity\s+\.console-splitter\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultsSplitter = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.console-editor-results-splitter\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(shellSplitter).toMatch(/width:\s*32px/i) + expect(shellSplitter).toMatch(/margin-left:\s*-15(?:\.5)?px/i) + expect(resultsSplitter).toMatch(/height:\s*32px/i) + expect(resultsSplitter).toMatch(/margin-top:\s*-15(?:\.5)?px/i) + }) + + it('keeps redis console controls at 32px hit targets and preserves single-scroll inspector layout', () => { + // Viewer action icons must stay at 32×32 for accessibility (the underlying invariant + // is hit-target size, not visual padding — the Direction A redesign kept the size + // and changed only the surrounding chrome). + expect(redisShellSource).toContain("h-[32px] w-[32px] items-center justify-center rounded-md hover:text-primary") + // CLI input row keeps its 32px line. + expect(redisShellSource).toContain("min-h-[32px] bg-transparent border-0 p-0 m-0") + expect(redisShellSource).toMatch(/min-height:\s*32px\s*!important/i) + expect(redisShellSource).toMatch(/line-height:\s*32px\s*!important/i) + // Key-list rows. + expect(redisShellSource).toContain("'flex min-h-[34px] items-center justify-between px-3 cursor-pointer border-l-2 transition-colors group '") + expect(redisShellSource).toContain("'flex min-h-[34px] items-center justify-between px-3 cursor-pointer border-l-2 '") + // Scroll-arrow buttons are gone in the redesign. + expect(redisShellSource).not.toContain("tApp('redis.shell.scrollKeysLeft')") + expect(redisShellSource).not.toContain("tApp('redis.shell.scrollKeysRight')") + // CLI sizing behavior preserved. + expect(redisShellSource).toContain('const effectiveCliHeight = computed(() => (viewportWidth.value <= 760 ? Math.min(cliHeight.value, 112) : cliHeight.value))') + expect(redisShellSource).toContain("window.addEventListener('resize', syncViewportWidth)") + expect(redisShellSource).toContain("window.removeEventListener('resize', syncViewportWidth)") + // Direction A invariants: the inspector outer column owns layout, not scroll. Only + // the inner code panel scrolls. The 3-card grid is replaced by an inline meta row. + expect(redisShellSource).toContain('class="redis-session-shell-main flex-1 min-h-0 flex flex-col bg-background-light dark:bg-background-dark min-w-0"') + expect(redisShellSource).not.toContain('class="flex-1 min-h-0 overflow-y-auto p-4 lg:p-6"') + expect(redisShellSource).not.toContain('class="grid grid-cols-1 gap-3 mb-3 sm:grid-cols-3 lg:mb-4"') + expect(redisShellSource).toContain('id="key-inline-meta"') + expect(redisShellSource).toContain('id="viewer-card"') + }) + + it('keeps sidebar footer actions from shrinking at medium desktop widths', () => { + expect(sidebarSource).toContain('class="app-nav-panel bg-sidebar/70') + expect(sidebarSource).toContain('class="app-nav-link flex items-center gap-3') + expect(sidebarSource).toContain('class="app-nav-label leading-tight whitespace-normal break-words"') + expect(sidebarSource).toContain('class="app-nav-footer flex items-center justify-between gap-2 px-1 pt-3"') + expect(sidebarSource).toContain('class="flex shrink-0 items-center justify-center w-10 h-10 rounded-full') + expect(themeToggleSource).toContain('class="theme-toggle flex shrink-0 items-center justify-center w-10 h-10 rounded-full') + }) + + it('keeps install guide copy buttons large enough to click', () => { + const installCopyButtons = css.match(/\.install-block\s+\.btn\.mini\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(installCopyButtons).toMatch(/justify-self:\s*start/i) + expect(installCopyButtons).toMatch(/min-height:\s*32px/i) + }) +}) diff --git a/frontend/src/__tests__/datasource-activity.test.ts b/frontend/src/__tests__/datasource-activity.test.ts new file mode 100644 index 0000000..dc4d491 --- /dev/null +++ b/frontend/src/__tests__/datasource-activity.test.ts @@ -0,0 +1,94 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +const pushMock = vi.fn() +let routeState = { name: 'datasources', params: {}, query: {} } as any + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: pushMock }), + useRoute: () => routeState, +})) + +describe('Datasource activity tracking', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasources', params: {}, query: {} } + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-21T10:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('auto-probes failed/unknown/expired on list open', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_failed', name: 'A', type: 'mysql', host: '', port: 0 } as any, + { id: 'ds_stale', name: 'B', type: 'postgresql', host: '', port: 0 } as any, + { id: 'ds_recent', name: 'C', type: 'redis', host: '', port: 0 } as any, + { id: 'ds_unknown', name: 'D', type: 'mongodb', host: '', port: 0 } as any, + ] + store.status['ds_failed'] = 'failed' + store.status['ds_stale'] = 'connected' + store.statusCheckedAt['ds_stale'] = Date.now() - 31 * 60 * 1000 + store.status['ds_recent'] = 'connected' + store.statusCheckedAt['ds_recent'] = Date.now() - 5 * 60 * 1000 + + const testSpy = vi.spyOn(api, 'testDatasource').mockResolvedValue(true) + + mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(testSpy).toHaveBeenCalledWith('ds_failed') + expect(testSpy).toHaveBeenCalledWith('ds_stale') + expect(testSpy).toHaveBeenCalledWith('ds_unknown') + expect(testSpy).not.toHaveBeenCalledWith('ds_recent') + }) + + it('auto-probes after datasources load', async () => { + const store = useAppStore() + const testSpy = vi.spyOn(api, 'testDatasource').mockResolvedValue(true) + + mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(testSpy).not.toHaveBeenCalled() + + store.datasources = [ + { id: 'ds_new', name: 'New', type: 'mysql', host: '', port: 0 } as any, + ] + + await flushPromises() + + expect(testSpy).toHaveBeenCalledWith('ds_new') + }) + + it('marks datasource active after listEntities success', async () => { + routeState = { name: 'console', params: { id: 'ds1' }, query: {} } + const store = useAppStore() + store.datasources = [ + { id: 'ds1', name: 'Primary', type: 'mysql', host: '', port: 0 } as any, + ] + + vi.spyOn(api, 'listEntities').mockResolvedValue([]) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + + const markSpy = vi.spyOn(store, 'markDatasourceActive') + + mount(ConsoleView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(markSpy).toHaveBeenCalledWith('ds1') + }) +}) diff --git a/frontend/src/__tests__/datasource-d1-mock-i18n.test.ts b/frontend/src/__tests__/datasource-d1-mock-i18n.test.ts new file mode 100644 index 0000000..90fa63d --- /dev/null +++ b/frontend/src/__tests__/datasource-d1-mock-i18n.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { tApp, setAppLocale, resetAppI18nForTest } from '@/modules/i18n/appI18n' +import { api } from '@/services/api' + +describe('D1 mock API i18n copy', () => { + afterEach(() => { + resetAppI18nForTest() + }) + + it('uses english i18n message for empty database name', async () => { + setAppLocale('en') + await expect(api.d1CreateCloudDatabase('acc_mock', 'token_mock', ' ')).rejects.toThrow( + tApp('validation.d1CreateDatabaseNameRequired'), + ) + }) + + it('uses chinese i18n message for empty database name', async () => { + setAppLocale('zh') + await expect(api.d1CreateCloudDatabase('acc_mock', 'token_mock', ' ')).rejects.toThrow( + tApp('validation.d1CreateDatabaseNameRequired'), + ) + }) +}) diff --git a/frontend/src/__tests__/datasource-delete-modal.test.ts b/frontend/src/__tests__/datasource-delete-modal.test.ts new file mode 100644 index 0000000..d3c96e7 --- /dev/null +++ b/frontend/src/__tests__/datasource-delete-modal.test.ts @@ -0,0 +1,62 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView delete modal', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('requires confirmation before deleting', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_1', + name: 'Redis', + type: 'redis', + host: 'localhost', + port: 6379, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + ] + + const deleteSpy = vi.spyOn(api, 'deleteDatasource').mockResolvedValue(true) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + + const deleteButton = wrapper.findAll('button').find((btn) => btn.text() === 'Delete') + expect(deleteButton).toBeTruthy() + await deleteButton!.trigger('click') + + expect(deleteSpy).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="datasource-delete-dialog"]').exists()).toBe(true) + + await wrapper.get('[data-testid="datasource-delete-confirm"]').trigger('click') + await flushPromises() + + expect(deleteSpy).toHaveBeenCalledWith('ds_1') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-chromadb.test.ts b/frontend/src/__tests__/datasource-form-chromadb.test.ts new file mode 100644 index 0000000..cba63ec --- /dev/null +++ b/frontend/src/__tests__/datasource-form-chromadb.test.ts @@ -0,0 +1,104 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' +import { tApp } from '@/modules/i18n/appI18n' + +const routeState: { name: string; params: Record; fullPath: string } = { + name: 'datasource-create', + params: {}, + fullPath: '/datasources/new', +} + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView ChromaDB', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState.name = 'datasource-create' + routeState.params = {} + routeState.fullPath = '/datasources/new' + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('saves chromadb datasource with defaults and token', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_chroma' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.chromadb')) + await wrapper.find('#ds-name').setValue('Local Chroma') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-port').setValue('8000') + await wrapper.find('#chromadb-api-token').setValue('token-123') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'chromadb', + host: '127.0.0.1', + port: 8000, + username: '', + authSource: '', + options: expect.objectContaining({ + scheme: 'http', + tenant: 'default_tenant', + database: 'default_database', + apiToken: 'token-123', + }), + }), + ) + }) + + it('uses localized chromadb tenant and database placeholders', async () => { + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.chromadb')) + + expect(wrapper.find('#chromadb-tenant').attributes('placeholder')).toBe(tApp('datasource.form.chromadb.tenantPlaceholder')) + expect(wrapper.find('#chromadb-database').attributes('placeholder')).toBe(tApp('datasource.form.chromadb.databasePlaceholder')) + }) + + it('requires host and port for chromadb', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_chroma' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.chromadb')) + await wrapper.find('#ds-name').setValue('Local Chroma') + await wrapper.find('#ds-host').setValue('') + await wrapper.find('#ds-port').setValue('') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain(tApp('validation.hostRequired')) + expect(wrapper.text()).toContain(tApp('validation.portRequired')) + expect(createSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/datasource-form-d1-legacy.test.ts b/frontend/src/__tests__/datasource-form-d1-legacy.test.ts new file mode 100644 index 0000000..40070ed --- /dev/null +++ b/frontend/src/__tests__/datasource-form-d1-legacy.test.ts @@ -0,0 +1,281 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { tApp } from '@/modules/i18n/appI18n' +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' + +const routeState = vi.hoisted(() => ({ + name: 'datasource-edit', + params: { id: 'ds_local' }, + fullPath: '/datasources/ds_local/edit', +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView D1 legacy mode', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_local' } + routeState.fullPath = '/datasources/ds_local/edit' + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_local', + name: 'Legacy Local D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + mode: 'local', + binding: 'legacy_local', + databaseId: 'local-db-id', + }, + } as any, + ]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('allows saving legacy local mode datasource without oauth account', async () => { + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_local' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Legacy Local D1') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledWith( + 'ds_local', + expect.objectContaining({ + type: 'd1', + options: expect.objectContaining({ + mode: 'local', + databaseId: 'local-db-id', + binding: 'legacy_local', + }), + }), + ) + expect(wrapper.text()).not.toContain(tApp('validation.d1OauthRequired')) + }) + + it('preserves hidden d1 runtime options when editing datasource', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_cloud', + name: 'Legacy Cloud D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + mode: 'cloud', + accountId: 'acc_cloud', + databaseId: 'db_cloud', + authMode: 'token', + apiToken: 'token_abc', + wranglerCommand: 'npx wrangler d1 execute', + persistPath: './custom/path', + }, + } as any, + ]) + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_cloud' } + routeState.fullPath = '/datasources/ds_cloud/edit' + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_cloud' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Legacy Cloud D1') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledWith( + 'ds_cloud', + expect.objectContaining({ + type: 'd1', + options: expect.objectContaining({ + mode: 'cloud', + accountId: 'acc_cloud', + databaseId: 'db_cloud', + authMode: 'token', + apiToken: 'token_abc', + wranglerCommand: 'npx wrangler d1 execute', + persistPath: './custom/path', + }), + }), + ) + }) + + it('keeps legacy dev metadata when saving without touching support dev', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_cloud_legacy_dev', + name: 'Legacy Cloud D1 Dev', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + mode: 'cloud', + accountId: 'acc_cloud', + databaseId: 'db_cloud', + databaseName: 'cloud-db', + binding: 'CLOUD_DB', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + migrationsDir: 'migrations/cloud-db', + }, + } as any, + ]) + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_cloud_legacy_dev' } + routeState.fullPath = '/datasources/ds_cloud_legacy_dev/edit' + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_cloud_legacy_dev' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const supportDevCheckbox = wrapper.find('#d1-support-dev') + expect(supportDevCheckbox.exists()).toBe(true) + expect((supportDevCheckbox.element as HTMLInputElement).checked).toBe(true) + + await wrapper.find('#ds-name').setValue('Legacy Cloud D1 Dev Renamed') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0][1] as any + expect(payload.type).toBe('d1') + expect(payload.options.wranglerConfigPath).toBe('/Users/demo/project/wrangler.toml') + expect(payload.options.migrationsDir).toBe('migrations/cloud-db') + expect('supportDev' in payload.options).toBe(false) + }) + + it('clears legacy dev metadata after explicitly disabling support dev', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_cloud_legacy_disable', + name: 'Legacy Cloud D1 Disable', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + mode: 'cloud', + accountId: 'acc_cloud', + databaseId: 'db_cloud', + databaseName: 'cloud-db', + binding: 'CLOUD_DB', + wranglerConfigPath: '/Users/demo/project/wrangler.toml', + migrationsDir: 'migrations/cloud-db', + }, + } as any, + ]) + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_cloud_legacy_disable' } + routeState.fullPath = '/datasources/ds_cloud_legacy_disable/edit' + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_cloud_legacy_disable' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const supportDevCheckbox = wrapper.find('#d1-support-dev') + expect(supportDevCheckbox.exists()).toBe(true) + expect((supportDevCheckbox.element as HTMLInputElement).checked).toBe(true) + + await supportDevCheckbox.setValue(false) + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0][1] as any + expect(payload.type).toBe('d1') + expect(payload.options.supportDev).toBe(false) + expect(payload.options.wranglerConfigPath).toBeUndefined() + expect(payload.options.migrationsDir).toBeUndefined() + }) + + it('does not mark database select when local mode is missing binding', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_local_missing_binding', + name: 'Legacy Local D1 Missing Binding', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + mode: 'local', + databaseId: 'local-db-id', + }, + } as any, + ]) + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_local_missing_binding' } + routeState.fullPath = '/datasources/ds_local_missing_binding/edit' + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_local_missing_binding' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(updateSpy).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('validation.d1BindingRequired')) + expect(wrapper.find('#d1-database-select').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/datasource-form-d1.test.ts b/frontend/src/__tests__/datasource-form-d1.test.ts new file mode 100644 index 0000000..99604d8 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-d1.test.ts @@ -0,0 +1,737 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { tApp } from '@/modules/i18n/appI18n' +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { selectDatasourceType } from './helpers/select-datasource-type' + +const mockRoute = { + name: 'datasource-create', + params: {} as Record, +} + +vi.mock('vue-router', () => ({ + useRoute: () => mockRoute, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView D1', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + mockRoute.name = 'datasource-create' + mockRoute.params = {} + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('saves d1 datasource with oauth account and selected database', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_123', + } as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + { id: 'db_orders', name: 'orders' }, + ] as any) + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_d1' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#ds-name').setValue('Cloud D1') + + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + expect((wrapper.find('#d1-account-select').element as HTMLSelectElement).value).toBe('acc_123') + + const databaseSelect = wrapper.find('#d1-database-select') + await databaseSelect.setValue('db_analytics') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + options: expect.objectContaining({ + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + apiToken: 'token_123', + }), + }), + ) + }) + + it('treats dev as disabled when support-dev checkbox is checked but project path is empty', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_123', + } as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_d1' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#ds-name').setValue('Cloud D1') + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + await wrapper.find('#d1-database-select').setValue('db_analytics') + await flushPromises() + + const devCheckbox = wrapper.find('#d1-support-dev') + expect(devCheckbox.exists()).toBe(true) + await devCheckbox.setValue(true) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + supportDev: false, + }), + }), + ) + const options = (createSpy.mock.calls[0]?.[0] as any)?.options || {} + expect(options.devProjectPath).toBeUndefined() + }) + + it('persists dev project path when support-dev is enabled with a local project path', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_123', + } as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_d1' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#ds-name').setValue('Cloud D1') + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + await wrapper.find('#d1-database-select').setValue('db_analytics') + await flushPromises() + + const devCheckbox = wrapper.find('#d1-support-dev') + expect(devCheckbox.exists()).toBe(true) + await devCheckbox.setValue(true) + await flushPromises() + + const projectPathInput = wrapper.find('#d1-dev-project-path') + expect(projectPathInput.exists()).toBe(true) + expect(projectPathInput.attributes('autocapitalize')).toBe('off') + expect(projectPathInput.attributes('autocorrect')).toBe('off') + expect(projectPathInput.attributes('spellcheck')).toBe('false') + await projectPathInput.setValue('/Users/test/workers/demo-app') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + supportDev: true, + devProjectPath: '/Users/test/workers/demo-app', + }), + }), + ) + }) + + it('creates a new d1 database from the first "+" option', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_123', + } as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + const createDatabaseSpy = vi.spyOn(api, 'd1CreateCloudDatabase').mockResolvedValue({ + id: 'db_new', + name: 'new-db', + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + await wrapper.find('#d1-database-select').setValue('__create__') + await flushPromises() + expect(wrapper.find('#d1-create-database-name').exists()).toBe(true) + await wrapper.find('#d1-create-database-name').setValue('new-db') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.form.d1.createDatabase'))!.trigger('click') + await flushPromises() + + expect(createDatabaseSpy).toHaveBeenCalledWith('acc_123', 'token_123', 'new-db') + expect((wrapper.find('#d1-database-select').element as HTMLSelectElement).value).toBe('db_new') + }) + + it('validates oauth and selected database before saving', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_d1' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#ds-name').setValue('Cloud D1') + expect(wrapper.find('#d1-database-select').exists()).toBe(false) + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('validation.d1OauthRequired')) + expect(wrapper.text()).toContain(tApp('validation.d1DatabaseIdRequired')) + }) + + it('loads d1 databases only once when selecting account after oauth', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [ + { id: 'acc_1', name: 'Team 1' }, + { id: 'acc_2', name: 'Team 2' }, + ], + accountId: 'acc_1', + token: 'token_123', + } as any) + const listSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + expect((wrapper.find('#d1-account-select').element as HTMLSelectElement).value).toBe('') + await wrapper.find('#d1-account-select').setValue('acc_1') + await flushPromises() + + expect(listSpy).toHaveBeenCalledTimes(1) + expect(listSpy).toHaveBeenCalledWith('acc_1', 'token_123') + }) + + it('clears stale d1 database options immediately when switching account', async () => { + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [ + { id: 'acc_1', name: 'Team 1' }, + { id: 'acc_2', name: 'Team 2' }, + ], + accountId: 'acc_1', + token: 'token_123', + } as any) + + let resolveSecondFetch: ((value: Array<{ id: string; name: string }>) => void) | null = null + const secondFetchPromise = new Promise>((resolve) => { + resolveSecondFetch = resolve + }) + const listSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockImplementation((accountId: string) => { + if (accountId === 'acc_1') { + return Promise.resolve([{ id: 'db_old', name: 'old-db' }] as any) + } + if (accountId === 'acc_2') { + return secondFetchPromise as any + } + return Promise.resolve([] as any) + }) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + await wrapper.find('#d1-account-select').setValue('acc_1') + await flushPromises() + + const oldOptions = wrapper.findAll('#d1-database-select option').map((node) => node.text()) + expect(oldOptions.some((text) => text.includes('old-db'))).toBe(true) + + await wrapper.find('#d1-account-select').setValue('acc_2') + await flushPromises() + + const optionsBeforeSecondLoad = wrapper.findAll('#d1-database-select option').map((node) => node.text()) + expect(optionsBeforeSecondLoad.some((text) => text.includes('old-db'))).toBe(false) + expect(listSpy).toHaveBeenCalledWith('acc_2', 'token_123') + + resolveSecondFetch?.([{ id: 'db_new', name: 'new-db' }]) + await flushPromises() + + const optionsAfterSecondLoad = wrapper.findAll('#d1-database-select option').map((node) => node.text()) + expect(optionsAfterSecondLoad.some((text) => text.includes('new-db'))).toBe(true) + }) + + it('shows connected state on oauth button for existing datasource when current token is still valid', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_existing' } + + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_existing', + name: 'Cloud D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + apiToken: 'token_123', + }, + }, + ] as any) + const listSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const oauthButton = wrapper.find('#d1-oauth-login') + expect(oauthButton.exists()).toBe(true) + expect(oauthButton.text()).toContain(tApp('status.connected')) + expect(oauthButton.classes()).toContain('success') + expect(listSpy).toHaveBeenCalledWith('acc_123', 'token_123') + }) + + it('re-authenticates from connected state without clearing selected account and reloads databases', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_existing' } + + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_existing', + name: 'Cloud D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + apiToken: 'token_old', + }, + }, + ] as any) + + const listSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockImplementation((_accountId: string, token: string) => { + if (token === 'token_old') { + return Promise.resolve([{ id: 'db_analytics', name: 'analytics' }] as any) + } + return Promise.resolve([{ id: 'db_new', name: 'new-db' }] as any) + }) + const reAuthSpy = vi.spyOn(api as any, 'd1OAuthReLogin').mockResolvedValue({ + accounts: [ + { id: 'acc_123', name: 'Team 123' }, + { id: 'acc_other', name: 'Other Team' }, + ], + accountId: 'acc_other', + token: 'token_new', + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(listSpy.mock.calls.some(([accountId, token]) => accountId === 'acc_123' && token === 'token_old')).toBe(true) + const callsBeforeClick = listSpy.mock.calls.length + + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + expect(reAuthSpy).toHaveBeenCalledTimes(1) + expect((wrapper.find('#d1-account-select').element as HTMLSelectElement).value).toBe('acc_123') + expect(listSpy.mock.calls.length).toBeGreaterThan(callsBeforeClick) + const callsAfterClick = listSpy.mock.calls.slice(callsBeforeClick) + expect(callsAfterClick.some(([accountId, token]) => accountId === 'acc_123' && token === 'token_new')).toBe(true) + + const databaseOptions = wrapper.findAll('#d1-database-select option').map((node) => node.text()) + expect(databaseOptions.some((text) => text.includes('new-db'))).toBe(true) + + const oauthButton = wrapper.find('#d1-oauth-login') + expect(oauthButton.text()).toContain(tApp('status.connected')) + expect(oauthButton.classes()).toContain('success') + }) + + it('shows refresh error instead of oauth success notice when re-auth refresh fails', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_existing' } + const store = useAppStore() + + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_existing', + name: 'Cloud D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + apiToken: 'token_old', + }, + }, + ] as any) + let listCalls = 0 + vi.spyOn(api, 'd1ListCloudDatabases').mockImplementation(() => { + listCalls += 1 + if (listCalls === 1) { + return Promise.resolve([{ id: 'db_analytics', name: 'analytics' }] as any) + } + return Promise.reject(new Error('refresh failed for account acc_123')) + }) + vi.spyOn(api as any, 'd1OAuthReLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_old', + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + + expect(store.notice.type).toBe('error') + expect(store.notice.message).toContain('refresh failed for account acc_123') + expect(store.notice.message).not.toBe(tApp('datasource.form.d1.oauthSuccess')) + }) + + it('renders d1 database creation result near test connection block', async () => { + const store = useAppStore() + vi.spyOn(api, 'd1OAuthLogin').mockResolvedValue({ + accounts: [{ id: 'acc_123', name: 'Team 123' }], + accountId: 'acc_123', + token: 'token_123', + } as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([ + { id: 'db_analytics', name: 'analytics' }, + ] as any) + vi.spyOn(api, 'd1CreateCloudDatabase').mockResolvedValue({ + id: 'db_new', + name: 'new-db', + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await wrapper.find('#d1-oauth-login').trigger('click') + await flushPromises() + const noticeBeforeCreate = store.notice.message + await wrapper.find('#d1-database-select').setValue('__create__') + await flushPromises() + + const nameInput = wrapper.find('#d1-create-database-name') + expect(nameInput.attributes('autocapitalize')).toBe('off') + expect(nameInput.attributes('autocorrect')).toBe('off') + expect(nameInput.attributes('spellcheck')).toBe('false') + + await nameInput.setValue('new-db') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.form.d1.createDatabase'))!.trigger('click') + await flushPromises() + + const createStatus = wrapper.find('[data-testid="d1-create-database-status"]') + expect(createStatus.exists()).toBe(true) + expect(createStatus.text()).toContain(tApp('status.success')) + expect(createStatus.text()).toContain('new-db') + expect(store.notice.message).toBe(noticeBeforeCreate) + }) + + it('shows red wrangler install warning when wrangler is unavailable', async () => { + vi.spyOn(api as any, 'd1IsWranglerInstalled').mockResolvedValue(false) + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await flushPromises() + + const warning = wrapper.find('[data-testid="d1-wrangler-missing-warning"]') + expect(warning.exists()).toBe(true) + expect(warning.text()).toContain(tApp('datasource.form.d1.wranglerInstallHint')) + expect(warning.classes()).toContain('d1-oauth-warning') + }) + + it('hides wrangler install warning when wrangler is available', async () => { + vi.spyOn(api as any, 'd1IsWranglerInstalled').mockResolvedValue(true) + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.d1')) + await flushPromises() + + expect(wrapper.find('[data-testid="d1-wrangler-missing-warning"]').exists()).toBe(false) + }) + + it('refreshes databases via the id-based binding when the stored token is redacted', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_redacted' } + + // The backend redacts options.apiToken to "[REDACTED]" in list/get payloads, so + // the edit form must refresh through the id-based binding (token resolved + // server-side) instead of calling the token API with the marker. + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_redacted', + name: 'Cloud D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + apiToken: '[REDACTED]', + }, + }, + ] as any) + const tokenListSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([] as any) + const idListSpy = vi + .spyOn(api as any, 'd1ListCloudDatabasesForDatasource') + .mockResolvedValue([{ id: 'db_analytics', name: 'analytics' }] as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(idListSpy).toHaveBeenCalledWith('ds_d1_redacted', 'acc_123') + expect(tokenListSpy).not.toHaveBeenCalledWith('acc_123', '[REDACTED]') + const oauthButton = wrapper.find('#d1-oauth-login') + expect(oauthButton.text()).toContain(tApp('status.connected')) + }) + + it('refreshes databases via the id-based binding for a SecretRef-backed token', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_ref' } + + // The token is delegated to a SecretRef, so options.apiToken is absent from the + // edit payload (resolved server-side). The form must still treat it as a stored + // token and refresh through the id-based binding rather than skipping the refresh. + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_ref', + name: 'Cloud D1 Vault', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + }, + secretRefs: { + 'options.apiToken': { + providerConfigId: 'vault-prod', + field: 'token', + key: 'cloudflare/d1/api-token', + }, + }, + }, + ] as any) + const tokenListSpy = vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([] as any) + const idListSpy = vi + .spyOn(api as any, 'd1ListCloudDatabasesForDatasource') + .mockResolvedValue([{ id: 'db_analytics', name: 'analytics' }] as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(idListSpy).toHaveBeenCalledWith('ds_d1_ref', 'acc_123') + expect(tokenListSpy).not.toHaveBeenCalled() + const oauthButton = wrapper.find('#d1-oauth-login') + expect(oauthButton.text()).toContain(tApp('status.connected')) + // The account/database selectors must stay visible even though the SecretRef case + // leaves the token field empty, so users can verify or change the Cloud database. + expect(wrapper.find('#d1-account-select').exists()).toBe(true) + expect((wrapper.find('#d1-account-select').element as HTMLSelectElement).value).toBe('acc_123') + expect(wrapper.find('#d1-database-select').exists()).toBe(true) + expect((wrapper.find('#d1-database-select').element as HTMLSelectElement).value).toBe('db_analytics') + // The "create database" option calls Cloudflare with the in-form token, which is + // empty here; it must stay hidden until the user supplies a fresh token. + const createOption = wrapper + .findAll('#d1-database-select option') + .find((node) => (node.element as HTMLOptionElement).value === '__create__') + expect(createOption).toBeUndefined() + }) + + it('preserves token auth mode and the apiToken ref when saving a SecretRef-backed D1 datasource', async () => { + mockRoute.name = 'datasource-edit' + mockRoute.params = { id: 'ds_d1_ref' } + + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_d1_ref', + name: 'Cloud D1 Vault', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + accountId: 'acc_123', + databaseId: 'db_analytics', + databaseName: 'analytics', + authMode: 'token', + }, + secretRefs: { + 'options.apiToken': { + providerConfigId: 'vault-prod', + field: 'token', + key: 'cloudflare/d1/api-token', + }, + }, + }, + ] as any) + vi.spyOn(api, 'd1ListCloudDatabases').mockResolvedValue([] as any) + vi.spyOn(api as any, 'd1ListCloudDatabasesForDatasource') + .mockResolvedValue([{ id: 'db_analytics', name: 'analytics' }] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_d1_ref' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + // Edit an unrelated field (the name) without re-supplying the token. + await wrapper.find('#ds-name').setValue('Cloud D1 Vault Renamed') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalled() + const saved = updateSpy.mock.calls[0]?.[1] as any + // Token auth must survive so D1Adapter uses the resolved token, not wrangler auth. + expect(saved.options.authMode).toBe('token') + // The inline token marker must never be persisted; the ref provides the secret. + expect(saved.options.apiToken).toBeUndefined() + expect(saved.secretRefs?.['options.apiToken']).toMatchObject({ + providerConfigId: 'vault-prod', + field: 'token', + key: 'cloudflare/d1/api-token', + }) + }) +}) diff --git a/frontend/src/__tests__/datasource-form-dynamodb.test.ts b/frontend/src/__tests__/datasource-form-dynamodb.test.ts new file mode 100644 index 0000000..3fda5d6 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-dynamodb.test.ts @@ -0,0 +1,500 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { selectDatasourceType } from './helpers/select-datasource-type' +import { tApp } from '@/modules/i18n/appI18n' + +const routeState: { name: string; params: Record; fullPath: string } = { + name: 'datasource-create', + params: {}, + fullPath: '/datasources/new', +} + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView DynamoDB', () => { + let pinia: ReturnType + const maskMiddle = (value: string) => { + if (value.length <= 8) return value + return `${value.slice(0, 4)}${'*'.repeat(value.length - 8)}${value.slice(-4)}` + } + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState.name = 'datasource-create' + routeState.params = {} + routeState.fullPath = '/datasources/new' + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('saves dynamodb datasource with region + endpoint', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_ddb' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ddb-auth-mode').setValue('profile') + + await wrapper.find('#ds-name').setValue('Mock DynamoDB') + await wrapper.find('#ddb-region').setValue('us-east-1') + await wrapper.find('#ddb-endpoint').setValue('http://127.0.0.1:4566') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'dynamodb', + host: '', + port: 0, + database: '', + options: expect.objectContaining({ + region: 'us-east-1', + endpoint: 'http://127.0.0.1:4566', + }), + }), + ) + }) + + it('requires region for dynamodb', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_ddb' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ddb-auth-mode').setValue('profile') + + await wrapper.find('#ds-name').setValue('Mock DynamoDB') + await wrapper.find('#ddb-region').setValue('') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Region is required.') + expect(createSpy).not.toHaveBeenCalled() + }) + + it('imports aws credentials file into static credentials', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_ddb' } as any) + const store = useAppStore() + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ddb-auth-mode').setValue('profile') + + await wrapper.find('#ds-name').setValue('Mock DynamoDB') + await wrapper.find('#ddb-region').setValue('us-east-1') + + const credentialsText = [ + '[default]', + 'aws_access_key_id = AKIA_TEST', + 'aws_secret_access_key = SECRET_TEST', + 'aws_session_token = TOKEN_TEST', + '', + ].join('\n') + const file = new File([credentialsText], 'credentials', { type: 'text/plain' }) + + const fileInput = wrapper.find('#ddb-credentials-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + expect((fileInput.element as any).files?.[0]).toBe(file) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + expect(store.notice.message).toBe('Imported AWS credentials (default).') + + expect(wrapper.find('#ddb-access-key-id').exists()).toBe(true) + expect((wrapper.find('#ddb-access-key-id').element as HTMLInputElement).value).toBe('AKIA_TEST') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'dynamodb', + options: expect.objectContaining({ + region: 'us-east-1', + credentials: { + accessKeyId: 'AKIA_TEST', + secretAccessKey: 'SECRET_TEST', + sessionToken: 'TOKEN_TEST', + }, + }), + }), + ) + }) + + it('defaults to sso mode and masks/copies sensitive SSO credentials', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'default', region: 'us-east-1' }, + ] as any) + const secretAccessKey = 'abcd12345678bbdw' + const sessionToken = 'token12345678bbdw' + vi.spyOn(api as any, 'dynamoDBSSOOAuthAuthorize').mockResolvedValue({ + profile: 'default', + region: 'us-east-1', + accountId: '111111111111', + roleName: 'Admin', + accessKeyId: 'AKIA12345678ABCD', + secretAccessKey, + sessionToken, + expiration: 1735689600000, + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await flushPromises() + + expect((wrapper.find('#ddb-auth-mode').element as HTMLSelectElement).value).toBe('sso') + + await wrapper.find('#ddb-sso-oauth').trigger('click') + await flushPromises() + + expect((wrapper.find('#ddb-secret-access-key').element as HTMLInputElement).value).toBe(maskMiddle(secretAccessKey)) + expect((wrapper.find('#ddb-session-token').element as HTMLTextAreaElement).value).toBe(maskMiddle(sessionToken)) + + await wrapper.find('#ddb-copy-secret-access-key').trigger('click') + await wrapper.find('#ddb-copy-session-token').trigger('click') + + expect(writeText).toHaveBeenNthCalledWith(1, secretAccessKey) + expect(writeText).toHaveBeenNthCalledWith(2, sessionToken) + }) + + it('keeps connected status and masked copyable credentials when reopening edit', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + const secretAccessKey = 'abcd12345678bbdw' + const sessionToken = 'token12345678bbdw' + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_dynamo_sso_edit', + name: 'Dynamo Edit', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + ssoCredentialExpiration: 1735689600000, + credentials: { + accessKeyId: 'AKIA12345678ABCD', + secretAccessKey, + sessionToken, + }, + }, + }, + ] as any) + vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'default', region: 'us-east-1' }, + ] as any) + + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_dynamo_sso_edit' } + routeState.fullPath = '/datasources/ds_dynamo_sso_edit/edit' + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const oauthButton = wrapper.find('#ddb-sso-oauth') + expect(oauthButton.text()).toContain(tApp('status.connected')) + expect((wrapper.find('#ddb-secret-access-key').element as HTMLInputElement).value).toBe(maskMiddle(secretAccessKey)) + expect((wrapper.find('#ddb-session-token').element as HTMLTextAreaElement).value).toBe(maskMiddle(sessionToken)) + + await wrapper.find('#ddb-copy-secret-access-key').trigger('click') + await wrapper.find('#ddb-copy-session-token').trigger('click') + expect(writeText).toHaveBeenNthCalledWith(1, secretAccessKey) + expect(writeText).toHaveBeenNthCalledWith(2, sessionToken) + }) + + it('allows saving an SSO datasource whose credentials are only SecretRef-backed', async () => { + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_dynamo_sso_ref' } as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_dynamo_sso_ref', + name: 'Dynamo Ref', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + ssoCredentialExpiration: 1735689600000, + }, + // The credentials live ONLY as preserved SecretRefs; no inline values. + secretRefs: { + 'options.credentials.accessKeyId': { providerConfigId: 'vault-dev', key: 'd/ak', field: 'accessKeyId' }, + 'options.credentials.secretAccessKey': { providerConfigId: 'vault-dev', key: 'd/sk', field: 'secretAccessKey' }, + 'options.credentials.sessionToken': { providerConfigId: 'vault-dev', key: 'd/st', field: 'sessionToken' }, + }, + }, + ] as any) + vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'default', region: 'us-east-1' }, + ] as any) + + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_dynamo_sso_ref' } + routeState.fullPath = '/datasources/ds_dynamo_sso_ref/edit' + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + // An unrelated edit must not be rejected for "missing" inline SSO credentials + // the datasource never carries — the resolvable refs satisfy that requirement. + await wrapper.find('#ds-name').setValue('Dynamo Ref Renamed') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(wrapper.text()).not.toContain(tApp('validation.dynamoSSOCredentialsRequired')) + expect(updateSpy).toHaveBeenCalled() + const saved = updateSpy.mock.calls[0]?.[1] as any + expect(saved.secretRefs?.['options.credentials.accessKeyId']).toMatchObject({ + providerConfigId: 'vault-dev', + key: 'd/ak', + field: 'accessKeyId', + }) + }) + + it('drops stale SSO credential refs when the SSO identity changes', async () => { + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_dynamo_sso_switch' } as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { + id: 'ds_dynamo_sso_switch', + name: 'Dynamo Ref', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + ssoCredentialExpiration: 1735689600000, + }, + // Temporary credentials for the loaded role live ONLY as preserved refs. + secretRefs: { + 'options.credentials.accessKeyId': { providerConfigId: 'vault-dev', key: 'd/ak', field: 'accessKeyId' }, + 'options.credentials.secretAccessKey': { providerConfigId: 'vault-dev', key: 'd/sk', field: 'secretAccessKey' }, + 'options.credentials.sessionToken': { providerConfigId: 'vault-dev', key: 'd/st', field: 'sessionToken' }, + }, + }, + ] as any) + vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'default', region: 'us-east-1', accountId: '111111111111', roleName: 'Admin' }, + { name: 'staging', region: 'us-east-1', accountId: '222222222222', roleName: 'ReadOnly' }, + ] as any) + + routeState.name = 'datasource-edit' + routeState.params = { id: 'ds_dynamo_sso_switch' } + routeState.fullPath = '/datasources/ds_dynamo_sso_switch/edit' + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + // Re-point the datasource at a different SSO identity without supplying new + // credentials. The previous role's credential refs are now stale. + await wrapper.find('#ddb-sso-profile').setValue('staging') + await flushPromises() + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + // The stale refs must not be re-emitted: the form requires re-authorization for + // the new role rather than silently persisting the old role's credentials. + expect(updateSpy).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('validation.dynamoSSOCredentialsRequired')) + }) + + it('authorizes dynamodb via single oauth action and persists role credentials', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_ddb_sso' } as any) + const listProfilesSpy = vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'default', region: 'us-east-1' }, + ] as any) + const oauthSpy = vi.spyOn(api as any, 'dynamoDBSSOOAuthAuthorize').mockResolvedValue({ + profile: 'default', + region: 'us-east-1', + accountId: '111111111111', + roleName: 'Admin', + accessKeyId: 'AKIA_SSO', + secretAccessKey: 'SECRET_SSO', + sessionToken: 'SESSION_SSO', + expiration: 1735689600000, + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ds-name').setValue('SSO DynamoDB') + await wrapper.find('#ddb-auth-mode').setValue('sso') + await flushPromises() + expect(listProfilesSpy).toHaveBeenCalledWith('') + await wrapper.find('#ddb-sso-oauth').trigger('click') + await flushPromises() + expect(oauthSpy).toHaveBeenCalledWith('default', 'us-east-1', '') + await flushPromises() + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'dynamodb', + options: expect.objectContaining({ + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + ssoCredentialExpiration: 1735689600000, + credentials: { + accessKeyId: 'AKIA_SSO', + secretAccessKey: 'SECRET_SSO', + sessionToken: 'SESSION_SSO', + }, + }), + }), + ) + }) + + it('loads profiles and authorizes with custom aws config path', async () => { + vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'zoom-sso-dev', region: 'ap-southeast-1', startUrl: 'https://example.awsapps.com/start' }, + ] as any) + const oauthSpy = vi.spyOn(api as any, 'dynamoDBSSOOAuthAuthorize').mockResolvedValue({ + profile: 'zoom-sso-dev', + region: 'ap-southeast-1', + accountId: '111111111111', + roleName: 'Developer', + accessKeyId: 'AKIA_SSO', + secretAccessKey: 'SECRET_SSO', + sessionToken: 'SESSION_SSO', + expiration: 1735689600000, + } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ddb-auth-mode').setValue('sso') + await flushPromises() + + await wrapper.find('#ddb-sso-config-path').setValue('/tmp/custom/aws/config') + await wrapper.find('#ddb-sso-config-apply').trigger('click') + await flushPromises() + + await wrapper.find('#ddb-sso-oauth').trigger('click') + await flushPromises() + + expect(oauthSpy).toHaveBeenCalledWith('zoom-sso-dev', 'ap-southeast-1', '/tmp/custom/aws/config') + }) + + it('applies config path and keeps manual region higher priority than config region', async () => { + const listProfilesSpy = vi.spyOn(api as any, 'dynamoDBSSOListProfiles').mockResolvedValue([ + { name: 'zoom-sso-dev', region: 'ap-southeast-1', startUrl: 'https://example.awsapps.com/start' }, + ] as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, tApp('datasource.type.dynamodb')) + await wrapper.find('#ddb-auth-mode').setValue('sso') + await flushPromises() + + await wrapper.find('#ddb-region').setValue('eu-west-1') + await wrapper.find('#ddb-sso-config-path').setValue('/tmp/custom/aws/config') + await wrapper.find('#ddb-sso-config-apply').trigger('click') + await flushPromises() + + expect(listProfilesSpy).toHaveBeenLastCalledWith('/tmp/custom/aws/config') + expect((wrapper.find('#ddb-sso-profile').element as HTMLSelectElement).value).toBe('zoom-sso-dev') + expect((wrapper.find('#ddb-region').element as HTMLInputElement).value).toBe('eu-west-1') + expect(wrapper.text()).toContain('https://example.awsapps.com/start') + expect(wrapper.find('#ddb-endpoint').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/datasource-form-elasticsearch-clears-database.test.ts b/frontend/src/__tests__/datasource-form-elasticsearch-clears-database.test.ts new file mode 100644 index 0000000..c2c905d --- /dev/null +++ b/frontend/src/__tests__/datasource-form-elasticsearch-clears-database.test.ts @@ -0,0 +1,53 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'datasource-create', params: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView Elasticsearch', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('clears database when switching from mysql to elasticsearch', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_es' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await selectDatasourceType(wrapper, 'Elasticsearch') + + await wrapper.find('#ds-name').setValue('Mock ES') + await wrapper.find('#ds-host').setValue('127.0.0.1') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'elasticsearch', + database: '', + }), + ) + }) +}) diff --git a/frontend/src/__tests__/datasource-form-mongo-uri.test.ts b/frontend/src/__tests__/datasource-form-mongo-uri.test.ts new file mode 100644 index 0000000..4c9311a --- /dev/null +++ b/frontend/src/__tests__/datasource-form-mongo-uri.test.ts @@ -0,0 +1,269 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' + +let routeState: any = { name: 'datasource-create', params: {} } + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView mongo uri', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasource-create', params: {} } + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('allows saving mongo uri without host/port', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mongo' } as any) + const store = useAppStore() + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await wrapper.find('#ds-name').setValue('Mongo URI') + await selectDatasourceType(wrapper, 'MongoDB') + + await wrapper.find('#ds-host').setValue('localhost') + await wrapper.find('#mongo-conn-mode').setValue('uri') + await flushPromises() + + await wrapper.find('#mongo-uri').setValue('mongodb://user:pass@host1:27017/db') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalled() + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.host).toBe('') + expect(payload.port).toBe(0) + expect(payload.options?.uri).toBe('mongodb://user:pass@host1:27017/db') + }) + + it('saves uploaded mongo certificate with tls option in uri mode', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mongo_uri_ssl' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Mongo URI SSL') + await selectDatasourceType(wrapper, 'MongoDB') + await wrapper.find('#mongo-conn-mode').setValue('uri') + await flushPromises() + await wrapper.find('#mongo-uri').setValue('mongodb://admin:mongo123456@192.168.50.201:30525/futrix?authSource=admin') + await wrapper.find('#mongo-tls').setValue(true) + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'mongo-uri-abc123', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'mongo-uri-ca.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#mongo-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.host).toBe('') + expect(payload.port).toBe(0) + expect(payload.options?.uri).toBe('mongodb://admin:mongo123456@192.168.50.201:30525/futrix?authSource=admin') + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nmongo-uri-abc123\n-----END CERTIFICATE-----') + }) + + it('saves uploaded mongo certificate with tls option in userpass mode', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mongo_ssl' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Mongo SSL') + await selectDatasourceType(wrapper, 'MongoDB') + await flushPromises() + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-port').setValue('27017') + await wrapper.find('#mongo-tls').setValue(true) + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'mongoabc123', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'mongo-server-ca.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#mongo-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('mongodb') + expect(payload.options?.tls).toBe(true) + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nmongoabc123\n-----END CERTIFICATE-----') + }) + + it('preserves mongo certificate on edit when not replaced', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_mongo_ssl_keep' } } + + const store = useAppStore() + const certPath = '/var/lib/futrix/certs/prod-mongo-root-ca.pem' + store.datasources = [ + { + id: 'ds_mongo_ssl_keep', + name: 'Mongo SSL', + type: 'mongodb', + host: '127.0.0.1', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: 'admin', + options: { + tls: true, + sslEnabled: true, + sslrootcert: certPath, + }, + } as any, + ] + const noticeSpy = vi.spyOn(store, 'setNotice') + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_mongo_ssl_keep' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#mongo-tls').element as HTMLInputElement).checked).toBe(true) + const certificateLink = wrapper.find('.pg-cert-link') + expect(certificateLink.exists()).toBe(true) + expect(certificateLink.text()).toBe('prod-mongo-root-ca.pem') + expect(wrapper.find('.pg-cert-meta.pg-cert-meta-success').exists()).toBe(true) + + noticeSpy.mockClear() + await certificateLink.trigger('click') + const pathNotice = noticeSpy.mock.calls.find(([message]) => String(message).includes(certPath)) + expect(pathNotice).toBeTruthy() + + await wrapper.find('#ds-name').setValue('Mongo SSL Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.tls).toBe(true) + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe(certPath) + }) + + it('overwrites mongo certificate on edit when a new file is uploaded', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_mongo_ssl_replace' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mongo_ssl_replace', + name: 'Mongo SSL', + type: 'mongodb', + host: '127.0.0.1', + port: 27017, + username: '', + password: '', + database: 'admin', + authSource: 'admin', + options: { + tls: true, + sslEnabled: true, + sslrootcert: '-----BEGIN CERTIFICATE-----\nOLD_MONGO_CERT\n-----END CERTIFICATE-----', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_mongo_ssl_replace' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'NEW_MONGO_CERT', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'mongo-replacement.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#mongo-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Mongo SSL Replaced Cert') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.tls).toBe(true) + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nNEW_MONGO_CERT\n-----END CERTIFICATE-----') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-redis-auth.test.ts b/frontend/src/__tests__/datasource-form-redis-auth.test.ts new file mode 100644 index 0000000..f03784d --- /dev/null +++ b/frontend/src/__tests__/datasource-form-redis-auth.test.ts @@ -0,0 +1,74 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'datasource-create', params: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView redis auth', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows password input when type is redis', async () => { + const store = useAppStore() + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await selectDatasourceType(wrapper, 'Redis') + + expect(wrapper.find('#ds-password').exists()).toBe(true) + }) + + it('clears database when switching to redis', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_redis' } as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + await wrapper.find('#ds-name').setValue('Redis') + await selectDatasourceType(wrapper, 'Redis') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-port').setValue('6379') + + const buttons = wrapper.findAll('button') + const saveButton = buttons.find((btn) => btn.text() === 'Save') + if (!saveButton) { + throw new Error('Save button not found') + } + await saveButton.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'redis', + database: '', + }), + ) + }) +}) diff --git a/frontend/src/__tests__/datasource-form-secret-ref.test.ts b/frontend/src/__tests__/datasource-form-secret-ref.test.ts new file mode 100644 index 0000000..d8ffac8 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-secret-ref.test.ts @@ -0,0 +1,535 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' + +let routeState: any = { name: 'datasource-create', params: {} } + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +const vaultProvider = { + id: 'vault-prod', + type: 'vault-kv-v2', + name: 'Vault Prod', + default: true, + address: 'https://vault.example.com', + mount: 'secret', +} + +describe('DatasourceFormView existing-secret reference', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasource-create', params: {} } + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('hides the secret-source selector when no providers are configured', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([]) + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + await selectDatasourceType(wrapper, 'MySQL') + await flushPromises() + expect(wrapper.find('#ds-password-secret-mode').exists()).toBe(false) + expect(wrapper.find('#ds-password').exists()).toBe(true) + }) + + it('sends a password secretRef without plaintext when referencing an existing secret', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_vault' } as any) + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Vault PG') + await selectDatasourceType(wrapper, 'PostgreSQL') + await flushPromises() + await wrapper.find('#ds-host').setValue('db.example.com') + await wrapper.find('#ds-username').setValue('postgres') + + await wrapper.find('#ds-password-secret-mode').setValue('existing') + await flushPromises() + await wrapper.find('#ds-password-secret-key').setValue('database/analytics/postgres') + await wrapper.find('#ds-password-secret-version').setValue('3') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.password).toBe('') + expect(payload.secretRefs?.password).toEqual({ + providerConfigId: 'vault-prod', + field: 'password', + key: 'database/analytics/postgres', + version: '3', + }) + }) + + it('blocks saving when the referenced secret is missing a key', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_vault' } as any) + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Vault PG') + await selectDatasourceType(wrapper, 'PostgreSQL') + await flushPromises() + await wrapper.find('#ds-host').setValue('db.example.com') + await wrapper.find('#ds-username').setValue('postgres') + + await wrapper.find('#ds-password-secret-mode').setValue('existing') + await flushPromises() + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + }) + + it('restores existing-secret mode from a stored datasource', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + const stored = { + id: 'ds_existing', + name: 'Stored Vault PG', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + password: { + providerConfigId: 'vault-prod', + field: 'password', + key: 'database/analytics/postgres', + version: '3', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_existing' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + const modeSelect = wrapper.find('#ds-password-secret-mode') + expect(modeSelect.exists()).toBe(true) + expect((modeSelect.element as HTMLSelectElement).value).toBe('existing') + expect((wrapper.find('#ds-password-secret-key').element as HTMLInputElement).value).toBe('database/analytics/postgres') + expect((wrapper.find('#ds-password-secret-version').element as HTMLInputElement).value).toBe('3') + }) + + it('clears the redacted marker when switching a ref-backed datasource to manual entry', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // The backend redacts the password of a ref-backed datasource to the marker; + // switching to manual must drop the marker so saving does not round-trip + // "[REDACTED]" (which the backend treats as "unchanged" and restores the ref). + const stored = { + id: 'ds_existing', + name: 'Stored Vault PG', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '[REDACTED]', + database: 'postgres', + options: {}, + secretRefs: { + password: { + providerConfigId: 'vault-prod', + field: 'password', + key: 'database/analytics/postgres', + version: '3', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_existing' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_existing' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-password-secret-mode').setValue('manual') + await flushPromises() + expect((wrapper.find('#ds-password').element as HTMLInputElement).value).toBe('') + + await wrapper.find('#ds-password').setValue('typed-secret') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.password).toBe('typed-secret') + expect(payload.secretRefs?.password).toBeUndefined() + }) + + it('keeps an inline password when toggling secret mode to existing and back to manual', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // An inline-password datasource (no password ref) also comes back redacted. + // Flipping the selector to existing and back must NOT wipe the sentinel, or the + // next save would send password:"" and the backend would erase the credential. + const stored = { + id: 'ds_inline', + name: 'Inline PG', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '[REDACTED]', + database: 'postgres', + options: {}, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_inline' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_inline' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-password-secret-mode').setValue('existing') + await flushPromises() + await wrapper.find('#ds-password-secret-mode').setValue('manual') + await flushPromises() + + await wrapper.find('#ds-name').setValue('Inline PG renamed') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.password).toBe('[REDACTED]') + expect(payload.secretRefs?.password).toBeUndefined() + }) + + it('drops a form-controlled option ref when the user supplies a new value', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // ChromaDB apiToken delegated to a secret ref; the form has a control for it, so + // a user-supplied value must win over the stale external ref. + const stored = { + id: 'ds_chroma_ref', + name: 'Chroma Vault', + type: 'chromadb', + host: '127.0.0.1', + port: 8000, + username: '', + password: '', + database: '', + options: { scheme: 'http', tenant: 'default_tenant', database: 'default_database' }, + secretRefs: { + 'options.apiToken': { + providerConfigId: 'vault-prod', + field: 'apiToken', + key: 'chroma/prod/token', + version: '1', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_chroma_ref' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_chroma_ref' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#chromadb-api-token').setValue('new-inline-token') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.apiToken).toBe('new-inline-token') + expect(payload.secretRefs?.['options.apiToken']).toBeUndefined() + }) + + it('preserves a form-controlled option ref when the user leaves the field untouched', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + const stored = { + id: 'ds_chroma_ref2', + name: 'Chroma Vault', + type: 'chromadb', + host: '127.0.0.1', + port: 8000, + username: '', + password: '', + database: '', + options: { scheme: 'http', tenant: 'default_tenant', database: 'default_database' }, + secretRefs: { + 'options.apiToken': { + providerConfigId: 'vault-prod', + field: 'apiToken', + key: 'chroma/prod/token', + version: '1', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_chroma_ref2' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_chroma_ref2' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Chroma Vault renamed') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs?.['options.apiToken']).toEqual({ + providerConfigId: 'vault-prod', + field: 'apiToken', + key: 'chroma/prod/token', + version: '1', + }) + }) + + it('preserves a non-password options.uri secret ref through the edit form', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // Created via API/CLI: connection string is delegated to an options.uri ref, + // so host/port/uri plaintext is absent. The UI has no control for this ref yet. + const stored = { + id: 'ds_uri', + name: 'URI Vault PG', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + 'options.uri': { + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_uri' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_uri' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + // Edit an unrelated field and save; the ref must survive and validation must + // not demand host/port/uri because the ref supplies the connection out of band. + await wrapper.find('#ds-name').setValue('URI Vault PG renamed') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs?.['options.uri']).toEqual({ + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }) + }) + + it('preserves the options.uri ref when host metadata is stored but the user edits an unrelated field', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // The datasource carries non-secret host/port metadata alongside the delegated + // URI ref. Editing an unrelated field must NOT look like the user took over the + // connection — the pre-existing host is the loaded baseline, not a new entry. + const stored = { + id: 'ds_uri_host', + name: 'URI Vault PG with host', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + 'options.uri': { + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_uri_host' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_uri_host' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('URI Vault PG renamed') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs?.['options.uri']).toEqual({ + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }) + }) + + it('drops the options.uri ref when the user supplies a direct host connection', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + const stored = { + id: 'ds_uri', + name: 'URI Vault PG', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + 'options.uri': { + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_uri' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_uri' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + // User takes over the connection by typing host/port/username; the stale + // external URI ref must be abandoned so it cannot shadow the new fields. + await wrapper.find('#ds-host').setValue('db.example.com') + await wrapper.find('#ds-port').setValue('5432') + await wrapper.find('#ds-username').setValue('postgres') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs).toBeUndefined() + }) + + it('drops the options.uri ref when the user adds a password SecretRef', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // The connection is delegated to options.uri, but the user now picks a discrete + // password SecretRef. SQL/Mongo adapters prefer options.uri, so a preserved URI + // ref would shadow the password ref at resolve time — the URI ref must drop. + const stored = { + id: 'ds_uri_pwref', + name: 'URI Vault PG', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + 'options.uri': { + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_uri_pwref' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_uri_pwref' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-password-secret-mode').setValue('existing') + await flushPromises() + await wrapper.find('#ds-password-secret-key').setValue('database/analytics/postgres') + await wrapper.find('#ds-password-secret-version').setValue('3') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs?.['options.uri']).toBeUndefined() + expect(payload.secretRefs?.password).toEqual({ + providerConfigId: 'vault-prod', + field: 'password', + key: 'database/analytics/postgres', + version: '3', + }) + }) + + it('drops the options.uri ref when the user edits a connection credential', async () => { + vi.spyOn(api, 'listSecretProviders').mockResolvedValue([vaultProvider] as any) + // Host/port metadata is stored alongside the URI ref, but the user edits the + // username. SQL/Mongo adapters prefer options.uri over individual fields, so a + // preserved ref would silently shadow the edited credential — the ref must drop. + const stored = { + id: 'ds_uri_cred', + name: 'URI Vault PG with host', + type: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'postgres', + password: '', + database: 'postgres', + options: {}, + secretRefs: { + 'options.uri': { + providerConfigId: 'vault-prod', + field: 'uri', + key: 'database/analytics/postgres-uri', + version: '2', + }, + }, + } + vi.spyOn(api, 'listDatasources').mockResolvedValue([stored] as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_uri_cred' } as any) + routeState = { name: 'datasource-edit', params: { id: 'ds_uri_cred' } } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-username').setValue('analytics_rw') + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + await saveButton!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.secretRefs).toBeUndefined() + }) +}) diff --git a/frontend/src/__tests__/datasource-form-sql-defaults.test.ts b/frontend/src/__tests__/datasource-form-sql-defaults.test.ts new file mode 100644 index 0000000..cdf71d3 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-sql-defaults.test.ts @@ -0,0 +1,468 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { selectDatasourceType } from './helpers/select-datasource-type' + +let routeState: any = { name: 'datasource-create', params: {} } +const pushMock = vi.fn() + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +describe('DatasourceFormView SQL defaults', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasource-create', params: {} } + pushMock.mockReset() + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('switches mysql/postgresql default port + database but keeps user overrides', async () => { + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#ds-port').element as HTMLInputElement).value).toBe('3306') + expect((wrapper.find('#ds-database').element as HTMLInputElement).value).toBe('mysql') + + await selectDatasourceType(wrapper, 'PostgreSQL') + + expect((wrapper.find('#ds-port').element as HTMLInputElement).value).toBe('5432') + expect((wrapper.find('#ds-database').element as HTMLInputElement).value).toBe('postgres') + + await wrapper.find('#ds-port').setValue('9999') + await wrapper.find('#ds-database').setValue('customdb') + + await selectDatasourceType(wrapper, 'MySQL') + + expect((wrapper.find('#ds-port').element as HTMLInputElement).value).toBe('9999') + expect((wrapper.find('#ds-database').element as HTMLInputElement).value).toBe('customdb') + }) + + it('shows JSON validation error when options are invalid', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds1' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('MySQL') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + await wrapper.find('#ds-options').setValue('{') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.find('.form-errors').text()).toContain('Options must be valid JSON.') + }) + + it('does not show AI provider config and strips aiConfigId from options payload', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mysql' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(wrapper.find('#ds-ai-provider').exists()).toBe(false) + + await wrapper.find('#ds-name').setValue('MySQL') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + await wrapper.find('#ds-options').setValue('{"aiConfigId":"ai_ok","sslmode":"disable"}') + + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.options).toEqual(expect.objectContaining({ sslmode: 'disable' })) + expect(payload.options.aiConfigId).toBeUndefined() + }) + + it('preserves existing aiConfigId when saving an edited datasource', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_1' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_1', + name: 'Prod MySQL', + type: 'mysql', + host: '10.0.0.11', + port: 3306, + username: 'root', + password: '', + database: 'mysql', + options: { + aiConfigId: 'ai_ds_specific', + sslmode: 'disable', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_1' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(wrapper.find('#ds-ai-provider').exists()).toBe(false) + expect((wrapper.find('#ds-options').element as HTMLTextAreaElement).value).not.toContain('aiConfigId') + + await wrapper.find('#ds-name').setValue('Prod MySQL Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + expect(updateSpy.mock.calls[0]?.[0]).toBe('ds_1') + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options).toEqual( + expect.objectContaining({ + sslmode: 'disable', + aiConfigId: 'ai_ds_specific', + }), + ) + }) + + it('keeps sql userpass mode for edited datasource when options.uri is non-string', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_legacy_uri' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_legacy_uri', + name: 'Legacy URI Type', + type: 'mysql', + host: '10.0.0.11', + port: 3306, + username: 'root', + password: '', + database: 'mysql', + options: { + uri: 123, + sslmode: 'disable', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_legacy_uri' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#sql-conn-mode').element as HTMLSelectElement).value).toBe('userpass') + expect((wrapper.find('#ds-host').element as HTMLInputElement).value).toBe('10.0.0.11') + expect((wrapper.find('#ds-port').element as HTMLInputElement).value).toBe('3306') + + await wrapper.find('#ds-name').setValue('Legacy URI Type Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.host).toBe('10.0.0.11') + expect(payload.port).toBe(3306) + expect(payload.username).toBe('root') + }) + + it('preserves existing postgresql certificate on edit when not replaced', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_pg_ssl_keep' } } + + const store = useAppStore() + const certPath = '/var/lib/futrix/certs/prod-root-ca.crt' + store.datasources = [ + { + id: 'ds_pg_ssl_keep', + name: 'Prod PG', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + options: { + uri: 'postgresql://postgres:secret@db.example.com:5432/postgres', + sslEnabled: true, + sslrootcert: certPath, + }, + } as any, + ] + const noticeSpy = vi.spyOn(store, 'setNotice') + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_pg_ssl_keep' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#sql-conn-mode').element as HTMLSelectElement).value).toBe('uri') + expect((wrapper.find('#pg-ssl-enabled').element as HTMLInputElement).checked).toBe(true) + const certificateLink = wrapper.find('.pg-cert-link') + expect(certificateLink.exists()).toBe(true) + expect(certificateLink.text()).toBe('prod-root-ca.crt') + expect(wrapper.find('.pg-cert-meta.pg-cert-meta-success').exists()).toBe(true) + + noticeSpy.mockClear() + await certificateLink.trigger('click') + const pathNotice = noticeSpy.mock.calls.find(([message]) => String(message).includes(certPath)) + expect(pathNotice).toBeTruthy() + + await wrapper.find('#ds-name').setValue('Prod PG Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe(certPath) + }) + + it('overwrites postgresql certificate on edit when a new file is uploaded', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_pg_ssl_replace' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_pg_ssl_replace', + name: 'Prod PG', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + options: { + uri: 'postgresql://postgres:secret@db.example.com:5432/postgres', + sslEnabled: true, + sslrootcert: '-----BEGIN CERTIFICATE-----\nOLD_CERT\n-----END CERTIFICATE-----', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_pg_ssl_replace' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'NEW_CERT', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'replacement.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#pg-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Prod PG Replaced Cert') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----') + }) + + it('infers postgresql ssl toggle from legacy sslmode when sslEnabled is missing', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_pg_sslmode_legacy' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_pg_sslmode_legacy', + name: 'Legacy PG SSL Mode', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + options: { + uri: 'postgresql://postgres:secret@db.example.com:5432/postgres', + sslmode: 'require', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_pg_sslmode_legacy' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#pg-ssl-enabled').element as HTMLInputElement).checked).toBe(true) + + await wrapper.find('#ds-name').setValue('Legacy PG SSL Mode Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.uri).toBe('postgresql://postgres:secret@db.example.com:5432/postgres') + }) + + it('preserves existing mysql certificate on edit when not replaced', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_mysql_ssl_keep' } } + + const store = useAppStore() + const certPath = '/var/lib/futrix/certs/prod-mysql-root-ca.pem' + store.datasources = [ + { + id: 'ds_mysql_ssl_keep', + name: 'Prod MySQL', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + options: { + uri: 'mysql://root:secret@db.example.com:3306/mysql', + sslEnabled: true, + sslrootcert: certPath, + }, + } as any, + ] + const noticeSpy = vi.spyOn(store, 'setNotice') + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_mysql_ssl_keep' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect((wrapper.find('#sql-conn-mode').element as HTMLSelectElement).value).toBe('uri') + expect((wrapper.find('#mysql-ssl-enabled').element as HTMLInputElement).checked).toBe(true) + const certificateLink = wrapper.find('.pg-cert-link') + expect(certificateLink.exists()).toBe(true) + expect(certificateLink.text()).toBe('prod-mysql-root-ca.pem') + expect(wrapper.find('.pg-cert-meta.pg-cert-meta-success').exists()).toBe(true) + + noticeSpy.mockClear() + await certificateLink.trigger('click') + const pathNotice = noticeSpy.mock.calls.find(([message]) => String(message).includes(certPath)) + expect(pathNotice).toBeTruthy() + + await wrapper.find('#ds-name').setValue('Prod MySQL Updated') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe(certPath) + }) + + it('overwrites mysql certificate on edit when a new file is uploaded', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_mysql_ssl_replace' } } + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql_ssl_replace', + name: 'Prod MySQL', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + options: { + uri: 'mysql://root:secret@db.example.com:3306/mysql', + sslEnabled: true, + sslrootcert: '-----BEGIN CERTIFICATE-----\nOLD_MYSQL_CERT\n-----END CERTIFICATE-----', + }, + } as any, + ] + + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_mysql_ssl_replace' } as any) + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'unused' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'NEW_MYSQL_CERT', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'mysql-replacement.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#mysql-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Prod MySQL Replaced Cert') + await wrapper.findAll('button').find((btn) => btn.text() === 'Save')!.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledTimes(1) + const payload = updateSpy.mock.calls[0]?.[1] as any + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nNEW_MYSQL_CERT\n-----END CERTIFICATE-----') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-sql-uri.test.ts b/frontend/src/__tests__/datasource-form-sql-uri.test.ts new file mode 100644 index 0000000..c5e0d67 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-sql-uri.test.ts @@ -0,0 +1,193 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { selectDatasourceType } from './helpers/select-datasource-type' + +let routeState: any = { name: 'datasource-create', params: {} } + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView sql uri', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasource-create', params: {} } + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('allows saving postgresql uri without host/port', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_pg' } as any) + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('PG URI') + await selectDatasourceType(wrapper, 'PostgreSQL') + await wrapper.find('#sql-conn-mode').setValue('uri') + await flushPromises() + await wrapper.find('#sql-uri').setValue('postgresql://postgres:secret@db.example.com:5432/postgres') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('postgresql') + expect(payload.host).toBe('') + expect(payload.port).toBe(0) + expect(payload.options?.uri).toBe('postgresql://postgres:secret@db.example.com:5432/postgres') + }) + + it('allows saving mysql uri without host/port', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mysql' } as any) + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('MySQL URI') + await selectDatasourceType(wrapper, 'MySQL') + await wrapper.find('#sql-conn-mode').setValue('uri') + await flushPromises() + await wrapper.find('#sql-uri').setValue('mysql://root:secret@db.example.com:3306/mysql') + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('mysql') + expect(payload.host).toBe('') + expect(payload.port).toBe(0) + expect(payload.options?.uri).toBe('mysql://root:secret@db.example.com:3306/mysql') + }) + + it('keeps postgresql direct url unchanged when ssl is disabled', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_pg' } as any) + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('PG URI No SSL') + await selectDatasourceType(wrapper, 'PostgreSQL') + await wrapper.find('#sql-conn-mode').setValue('uri') + await flushPromises() + const directURL = 'postgresql://postgres:secret@db.example.com:5432/postgres?sslmode=require' + await wrapper.find('#sql-uri').setValue(directURL) + + expect((wrapper.find('#pg-ssl-enabled').element as HTMLInputElement).checked).toBe(false) + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('postgresql') + expect(payload.options?.uri).toBe(directURL) + expect(payload.options?.sslEnabled).toBe(false) + expect(payload.options?.sslrootcert).toBeUndefined() + }) + + it('saves uploaded postgresql certificate and ssl option', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_pg_ssl' } as any) + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('PG URI SSL') + await selectDatasourceType(wrapper, 'PostgreSQL') + await wrapper.find('#sql-conn-mode').setValue('uri') + await flushPromises() + await wrapper.find('#sql-uri').setValue('postgresql://postgres:secret@db.example.com:5432/postgres') + await wrapper.find('#pg-ssl-enabled').setValue(true) + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'abc123', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'server-ca.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#pg-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('postgresql') + expect(payload.options?.uri).toBe('postgresql://postgres:secret@db.example.com:5432/postgres') + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nabc123\n-----END CERTIFICATE-----') + }) + + it('saves uploaded mysql certificate and ssl option in direct url mode', async () => { + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_mysql_ssl' } as any) + const wrapper = mount(DatasourceFormView, { + global: { plugins: [pinia] }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('MySQL URI SSL') + await selectDatasourceType(wrapper, 'MySQL') + await wrapper.find('#sql-conn-mode').setValue('uri') + await flushPromises() + await wrapper.find('#sql-uri').setValue('mysql://root:secret@db.example.com:3306/mysql') + await wrapper.find('#mysql-ssl-enabled').setValue(true) + + const certText = [ + '-----BEGIN CERTIFICATE-----', + 'mysqlabc123', + '-----END CERTIFICATE-----', + '', + ].join('\n') + const file = new File([certText], 'mysql-server-ca.pem', { type: 'application/x-pem-file' }) + const fileInput = wrapper.find('#mysql-ssl-certificate-file') + Object.defineProperty(fileInput.element, 'files', { value: [file], configurable: true }) + await fileInput.trigger('change') + await flushPromises() + await new Promise((resolve) => setTimeout(resolve, 0)) + await flushPromises() + + const saveButton = wrapper.findAll('button').find((btn) => btn.text() === 'Save') + expect(saveButton).toBeTruthy() + await saveButton!.trigger('click') + await flushPromises() + + expect(createSpy).toHaveBeenCalledTimes(1) + const payload = createSpy.mock.calls[0]?.[0] as any + expect(payload.type).toBe('mysql') + expect(payload.options?.uri).toBe('mysql://root:secret@db.example.com:3306/mysql') + expect(payload.options?.sslEnabled).toBe(true) + expect(payload.options?.sslrootcert).toBe('-----BEGIN CERTIFICATE-----\nmysqlabc123\n-----END CERTIFICATE-----') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-test-connection-side-effects.test.ts b/frontend/src/__tests__/datasource-form-test-connection-side-effects.test.ts new file mode 100644 index 0000000..9865457 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-test-connection-side-effects.test.ts @@ -0,0 +1,93 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'datasource-create', params: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView Test Connection side effects', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + delete (api as any).testDatasourcePayload + vi.restoreAllMocks() + }) + + it('does not create or update a datasource record when testing a new datasource', async () => { + const store = useAppStore() + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_1' } as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_1' } as any) + const payloadSpy = vi.fn().mockResolvedValue(true) + ;(api as any).testDatasourcePayload = payloadSpy + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('MySQL Local') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + + const testButton = wrapper.findAll('button').find((btn) => btn.text() === 'Test Connection') + expect(testButton).toBeTruthy() + + await testButton!.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="datasource-form-test-connection"]').text()).toContain('Connected') + expect(store.notice.message).toBe('') + + expect(createSpy).not.toHaveBeenCalled() + expect(updateSpy).not.toHaveBeenCalled() + expect(payloadSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'MySQL Local', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: 'root', + }), + '', + ) + expect(store.formMode).toBe('create') + expect(store.formId).toBe(null) + }) + + it('renders inline failure status without using the global notice banner', async () => { + const store = useAppStore() + vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_1' } as any) + vi.spyOn(api, 'updateDatasource').mockResolvedValue({ id: 'ds_1' } as any) + const payloadSpy = vi.fn().mockRejectedValue(new Error('Connection refused')) + ;(api as any).testDatasourcePayload = payloadSpy + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('MySQL Local') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + + const testButton = wrapper.findAll('button').find((btn) => btn.text() === 'Test Connection') + expect(testButton).toBeTruthy() + + await testButton!.trigger('click') + await flushPromises() + + const blockText = wrapper.find('[data-testid="datasource-form-test-connection"]').text() + expect(blockText).toContain('Failed') + expect(blockText).toContain('Connection refused') + expect(store.notice.message).toBe('') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-title.test.ts b/frontend/src/__tests__/datasource-form-title.test.ts new file mode 100644 index 0000000..714218d --- /dev/null +++ b/frontend/src/__tests__/datasource-form-title.test.ts @@ -0,0 +1,54 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' + +const pushMock = vi.fn() +let routeState = { name: 'datasource-create', params: {} } as any + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +describe('DatasourceFormView title', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + routeState = { name: 'datasource-create', params: {} } + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows "New Data Source" in create mode', async () => { + routeState = { name: 'datasource-create', params: {} } + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.find('#view-form .list-toolbar h2').text()).toBe('New Data Source') + }) + + it('shows "Edit Data Source" in edit mode', async () => { + routeState = { name: 'datasource-edit', params: { id: 'ds_1' } } + + const store = useAppStore() + store.datasources = [ + { id: 'ds_1', name: 'Primary', type: 'mysql', host: 'localhost', port: 3306, username: '', password: '', options: {} } as any, + ] + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.find('#view-form .list-toolbar h2').text()).toBe('Edit Data Source') + }) +}) diff --git a/frontend/src/__tests__/datasource-form-type-dropdown-icons.test.ts b/frontend/src/__tests__/datasource-form-type-dropdown-icons.test.ts new file mode 100644 index 0000000..ad48895 --- /dev/null +++ b/frontend/src/__tests__/datasource-form-type-dropdown-icons.test.ts @@ -0,0 +1,41 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'datasource-create', params: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceFormView datasource type dropdown icons', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders svg icons for datasource type options', async () => { + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-type').trigger('click') + await flushPromises() + + const icons = wrapper.findAll('.ds-type-select-option-icon') + expect(icons.length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/src/__tests__/datasource-list-css.test.ts b/frontend/src/__tests__/datasource-list-css.test.ts new file mode 100644 index 0000000..2a23053 --- /dev/null +++ b/frontend/src/__tests__/datasource-list-css.test.ts @@ -0,0 +1,48 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('datasource list CSS', () => { + it('fills available space with responsive card columns', () => { + const css = loadStyleCss() + const block = css.match(/\.cards\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('display: grid') + expect(block).toContain('grid-template-columns: repeat(auto-fit, minmax(260px, 1fr))') + expect(block).not.toContain('max-width: 1200px') + }) + + it('clamps endpoint text to two lines with ellipsis', () => { + const css = loadStyleCss() + const block = css.match(/\.endpoint-text\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('-webkit-line-clamp: 2') + expect(block).toContain('overflow: hidden') + }) + + it('styles copy button with raised pill treatment', () => { + const css = loadStyleCss() + const block = css.match(/\.copy-button\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('width: 32px') + expect(block).toContain('height: 32px') + expect(block).toContain('border-radius: 10px') + expect(block).toContain('box-shadow') + }) + + it('truncates datasource error details to a single line', () => { + const css = loadStyleCss() + const block = css.match(/\.status-detail-text\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('text-overflow: ellipsis') + expect(block).toContain('white-space: nowrap') + expect(block).toContain('overflow: hidden') + }) +}) diff --git a/frontend/src/__tests__/datasource-list-db-display.test.ts b/frontend/src/__tests__/datasource-list-db-display.test.ts new file mode 100644 index 0000000..04208bd --- /dev/null +++ b/frontend/src/__tests__/datasource-list-db-display.test.ts @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView database labels', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + it('shows db for SQL but not for redis', () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'A Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + database: '0', + username: '', + password: '', + options: {}, + }, + { + id: 'ds_mysql', + name: 'B MySQL', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + database: 'appdb', + username: '', + password: '', + options: {}, + }, + ] + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + + const cards = wrapper.findAll('.datasource-card') + expect(cards[0].text()).not.toContain('db:') + expect(cards[1].text()).toContain('db: appdb') + }) +}) diff --git a/frontend/src/__tests__/datasource-list-endpoint-copy.test.ts b/frontend/src/__tests__/datasource-list-endpoint-copy.test.ts new file mode 100644 index 0000000..63f36fa --- /dev/null +++ b/frontend/src/__tests__/datasource-list-endpoint-copy.test.ts @@ -0,0 +1,398 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView endpoint copy', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + it('redacts sql uri credentials for endpoint display/copy and keeps existing fallbacks', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const mongoUri = 'mongodb://user:pass@host1:27017,host2:27017/db?replicaSet=rs0' + const mysqlUri = 'mysql://root:secret@db.example.com:3306/app' + const mysqlUriRedacted = 'mysql://db.example.com:3306/app' + const mysqlDsn = 'root:secret@tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnRedacted = 'tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithAtInPassword = 'root:p@ss@tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithAtInPasswordRedacted = 'tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithSlashInPassword = 'root:pa/ss@tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithSlashInPasswordRedacted = 'tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithParenInPassword = 'root:pa(ss@tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithParenInPasswordRedacted = 'tcp(db.example.com:3306)/app?parseTime=true' + const mysqlDsnWithAtInQuery = 'root:secret@tcp(db.example.com:3306)/app?trace=user@example.com' + const mysqlDsnWithAtInQueryRedacted = 'tcp(db.example.com:3306)/app?trace=user@example.com' + const mysqlDsnWithoutDbSlash = 'root:secret@tcp(db.example.com:3306)' + const mysqlDsnWithoutDbSlashRedacted = 'tcp(db.example.com:3306)' + const pgUri = 'postgresql://postgres:secret@db.example.com:5432/postgres' + const pgUriRedacted = 'postgresql://db.example.com:5432/postgres' + const malformedPgUri = 'postgresql://postgres:secret@/postgres' + const malformedPgUriRedacted = 'postgresql:///postgres' + const pgUriWithQueryAuth = 'postgresql://db.example.com:5432/postgres?user=alice&password=secret&sslmode=require' + const pgKeywordDsn = 'host=db.example.com port=5432 user=postgres password=secret dbname=postgres sslmode=require' + const pgKeywordDsnRedacted = 'host=db.example.com port=5432 user=*** password=*** dbname=postgres sslmode=require' + const pgKeywordDsnWithQuotes = `host=db.example.com user='alice' password="secret value" dbname=postgres` + const pgKeywordDsnWithQuotesRedacted = `host=db.example.com user='***' password="***" dbname=postgres` + const dynamoEndpoint = 'http://127.0.0.1:8000' + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mongo', + name: 'A Mongo', + type: 'mongodb', + host: '', + port: 0, + username: '', + password: '', + database: 'db', + authSource: '', + options: { uri: mongoUri }, + }, + { + id: 'ds_mysql', + name: 'B MySQL', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + { + id: 'ds_mysql_non_string_uri', + name: 'MySQL Non-String URI', + type: 'mysql', + host: '10.1.1.9', + port: 3307, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: 123 as any, + }, + }, + { + id: 'ds_mysql_uri', + name: 'MySQL URI', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlUri, + }, + }, + { + id: 'ds_mysql_dsn', + name: 'MySQL DSN', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsn, + }, + }, + { + id: 'ds_mysql_dsn_at', + name: 'MySQL DSN @ Password', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsnWithAtInPassword, + }, + }, + { + id: 'ds_mysql_dsn_slash', + name: 'MySQL DSN / Password', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsnWithSlashInPassword, + }, + }, + { + id: 'ds_mysql_dsn_query_at', + name: 'MySQL DSN Query @', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsnWithAtInQuery, + }, + }, + { + id: 'ds_mysql_dsn_paren', + name: 'MySQL DSN ( Password', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsnWithParenInPassword, + }, + }, + { + id: 'ds_mysql_dsn_no_slash', + name: 'MySQL DSN No Slash', + type: 'mysql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: mysqlDsnWithoutDbSlash, + }, + }, + { + id: 'ds_pg_uri', + name: 'PG URI', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: pgUri, + }, + }, + { + id: 'ds_pg_malformed', + name: 'PG Malformed URI', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: malformedPgUri, + }, + }, + { + id: 'ds_pg_query_auth', + name: 'PG Query Auth URI', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: pgUriWithQueryAuth, + }, + }, + { + id: 'ds_pg_keyword_dsn', + name: 'PG Keyword DSN', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: pgKeywordDsn, + }, + }, + { + id: 'ds_pg_keyword_dsn_quotes', + name: 'PG Keyword DSN Quotes', + type: 'postgresql', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + uri: pgKeywordDsnWithQuotes, + }, + }, + { + id: 'ds_ddb', + name: 'C DynamoDB', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { region: 'us-east-1', endpoint: dynamoEndpoint }, + }, + ] + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + + const copyButtons = wrapper.findAll('[data-testid="datasource-endpoint-copy"]') + expect(copyButtons).toHaveLength(16) + const firstButton = copyButtons[0] + expect(firstButton.attributes('aria-label')).toBe('Copy endpoint') + expect(firstButton.find('svg').exists()).toBe(true) + + const cardByName = (name: string) => wrapper.findAll('.datasource-card').find((card) => card.text().includes(name)) + const mongoCard = cardByName('A Mongo') + const mysqlCard = cardByName('B MySQL') + const mysqlNonStringUriCard = cardByName('MySQL Non-String URI') + const mysqlUriCard = cardByName('MySQL URI') + const mysqlDsnCard = cardByName('MySQL DSN') + const mysqlDsnAtCard = cardByName('MySQL DSN @ Password') + const mysqlDsnSlashCard = cardByName('MySQL DSN / Password') + const mysqlDsnQueryAtCard = cardByName('MySQL DSN Query @') + const mysqlDsnParenCard = cardByName('MySQL DSN ( Password') + const mysqlDsnNoSlashCard = cardByName('MySQL DSN No Slash') + const pgUriCard = cardByName('PG URI') + const malformedPgUriCard = cardByName('PG Malformed URI') + const pgQueryAuthCard = cardByName('PG Query Auth URI') + const pgKeywordDsnCard = cardByName('PG Keyword DSN') + const pgKeywordDsnQuotesCard = cardByName('PG Keyword DSN Quotes') + const ddbCard = cardByName('C DynamoDB') + + expect(mongoCard).toBeTruthy() + expect(mysqlCard).toBeTruthy() + expect(mysqlNonStringUriCard).toBeTruthy() + expect(mysqlUriCard).toBeTruthy() + expect(mysqlDsnCard).toBeTruthy() + expect(mysqlDsnAtCard).toBeTruthy() + expect(mysqlDsnSlashCard).toBeTruthy() + expect(mysqlDsnQueryAtCard).toBeTruthy() + expect(mysqlDsnParenCard).toBeTruthy() + expect(mysqlDsnNoSlashCard).toBeTruthy() + expect(pgUriCard).toBeTruthy() + expect(malformedPgUriCard).toBeTruthy() + expect(pgQueryAuthCard).toBeTruthy() + expect(pgKeywordDsnCard).toBeTruthy() + expect(pgKeywordDsnQuotesCard).toBeTruthy() + expect(ddbCard).toBeTruthy() + + await mongoCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mongoUri) + + await mysqlCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith('127.0.0.1:3306') + + await mysqlNonStringUriCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith('10.1.1.9:3307') + + await mysqlUriCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlUriRedacted) + + await mysqlDsnCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnRedacted) + + await mysqlDsnAtCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnWithAtInPasswordRedacted) + + await mysqlDsnSlashCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnWithSlashInPasswordRedacted) + + await mysqlDsnQueryAtCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnWithAtInQueryRedacted) + + await mysqlDsnParenCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnWithParenInPasswordRedacted) + + await mysqlDsnNoSlashCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(mysqlDsnWithoutDbSlashRedacted) + + await pgUriCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(pgUriRedacted) + + await malformedPgUriCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(malformedPgUriRedacted) + + await pgQueryAuthCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + const pgQueryAuthCopied = writeText.mock.calls[writeText.mock.calls.length - 1]?.[0] as string + expect(pgQueryAuthCopied).toContain('user=***') + expect(pgQueryAuthCopied).toContain('password=***') + expect(pgQueryAuthCopied).not.toContain('user=alice') + expect(pgQueryAuthCopied).not.toContain('password=secret') + + await pgKeywordDsnCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(pgKeywordDsnRedacted) + + await pgKeywordDsnQuotesCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(pgKeywordDsnWithQuotesRedacted) + + await ddbCard!.find('[data-testid="datasource-endpoint-copy"]').trigger('click') + expect(writeText).toHaveBeenCalledWith(dynamoEndpoint) + + expect(wrapper.text()).toContain(mysqlUriRedacted) + expect(wrapper.text()).toContain(mysqlDsnRedacted) + expect(wrapper.text()).toContain(mysqlDsnWithAtInPasswordRedacted) + expect(wrapper.text()).toContain(mysqlDsnWithSlashInPasswordRedacted) + expect(wrapper.text()).toContain(mysqlDsnWithAtInQueryRedacted) + expect(wrapper.text()).toContain(mysqlDsnWithParenInPasswordRedacted) + expect(wrapper.text()).toContain(mysqlDsnWithoutDbSlashRedacted) + expect(wrapper.text()).toContain(pgUriRedacted) + expect(wrapper.text()).toContain(malformedPgUriRedacted) + expect(wrapper.text()).toContain('user=***') + expect(wrapper.text()).toContain('password=***') + expect(wrapper.text()).toContain(pgKeywordDsnRedacted) + expect(wrapper.text()).toContain(pgKeywordDsnWithQuotesRedacted) + expect(wrapper.text()).not.toContain('root:secret@') + expect(wrapper.text()).not.toContain('root:p@ss@') + expect(wrapper.text()).not.toContain('root:pa/ss@') + expect(wrapper.text()).not.toContain('root:pa(ss@') + expect(wrapper.text()).not.toContain('root:secret@tcp') + expect(wrapper.text()).not.toContain('root:secret@tcp(db.example.com:3306)') + expect(wrapper.text()).not.toContain('postgres:secret@') + expect(wrapper.text()).not.toContain('user=alice') + expect(wrapper.text()).not.toContain('password=secret') + expect(wrapper.text()).not.toContain("user='alice'") + expect(wrapper.text()).not.toContain('password="secret value"') + }) +}) diff --git a/frontend/src/__tests__/datasource-list-icons.test.ts b/frontend/src/__tests__/datasource-list-icons.test.ts new file mode 100644 index 0000000..78c8d30 --- /dev/null +++ b/frontend/src/__tests__/datasource-list-icons.test.ts @@ -0,0 +1,76 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' +import { getDatasourceTypeIconUrl } from '@/modules/datasource/icons' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView datasource icons', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + it('renders datasource type svg icon in each card', () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_mysql', + name: 'MySQL Primary', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + options: {}, + }, + { + id: 'ds_redis_cluster', + name: 'Redis Cluster', + type: 'redis_cluster' as any, + host: 'localhost', + port: 6379, + username: '', + password: '', + options: {}, + }, + { + id: 'ds_d1', + name: 'Cloud D1', + type: 'd1' as any, + host: '', + port: 0, + username: '', + password: '', + options: { mode: 'cloud', accountId: 'acc_123', databaseId: 'db_123' }, + }, + ] + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + + const cards = wrapper.findAll('.datasource-card') + expect(cards.length).toBe(3) + + const mysqlCard = cards.find((card) => card.text().includes('MySQL Primary')) + const redisCard = cards.find((card) => card.text().includes('Redis Cluster')) + const d1Card = cards.find((card) => card.text().includes('Cloud D1')) + expect(mysqlCard).toBeTruthy() + expect(redisCard).toBeTruthy() + expect(d1Card).toBeTruthy() + + expect(mysqlCard!.find('.datasource-type-icon').attributes('src')).toBe(getDatasourceTypeIconUrl('mysql')) + expect(redisCard!.find('.datasource-type-icon').attributes('src')).toBe(getDatasourceTypeIconUrl('redis')) + expect(d1Card!.find('.datasource-type-icon').attributes('src')).toBe(getDatasourceTypeIconUrl('d1')) + }) +}) diff --git a/frontend/src/__tests__/datasource-list-metrics.test.ts b/frontend/src/__tests__/datasource-list-metrics.test.ts new file mode 100644 index 0000000..953a793 --- /dev/null +++ b/frontend/src/__tests__/datasource-list-metrics.test.ts @@ -0,0 +1,73 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView metrics', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.restoreAllMocks() + }) + + it('loads metrics after Test but does not render runtime or AI badge on cards', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_pg', + name: 'Postgres', + type: 'postgresql', + host: '127.0.0.1', + port: 5432, + username: 'postgres', + password: '', + database: 'postgres', + options: { aiConfigId: 'ai_any' }, + }, + ] + store.status['ds_pg'] = 'connected' + store.statusCheckedAt['ds_pg'] = Date.now() + + vi.spyOn(api, 'testDatasource').mockResolvedValue(true as any) + const metricsSpy = vi.fn().mockResolvedValue({ + datasourceId: 'ds_pg', + datasourceType: 'postgresql', + collectedAt: Date.now(), + cpuAvailable: true, + cpuPercent: 41.2, + memoryAvailable: true, + memoryUsedBytes: 2147483648, + memoryTotalBytes: 4294967296, + memoryUsedText: '2.00 GB', + memoryTotalText: '4.00 GB', + }) + ;(api as any).getDatasourceMetrics = metricsSpy + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const testButton = wrapper.findAll('button').find((btn) => btn.text() === 'Test') + expect(testButton).toBeTruthy() + + await testButton!.trigger('click') + await flushPromises() + + expect(metricsSpy).toHaveBeenCalledWith('ds_pg') + const metricsRow = wrapper.find('[data-testid="datasource-metrics-row"]') + expect(metricsRow.exists()).toBe(false) + expect(wrapper.find('.pill-ai').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/datasource-list-sort-status.test.ts b/frontend/src/__tests__/datasource-list-sort-status.test.ts new file mode 100644 index 0000000..531fd66 --- /dev/null +++ b/frontend/src/__tests__/datasource-list-sort-status.test.ts @@ -0,0 +1,49 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView sort: status', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not drop datasources when sorting by status', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_1', name: 'Alpha', type: 'mysql', host: 'localhost', port: 3306, username: '', password: '', options: {} } as any, + { id: 'ds_2', name: 'Bravo', type: 'mongodb', host: 'localhost', port: 27017, username: '', password: '', options: {} } as any, + { id: 'ds_3', name: 'Charlie', type: 'redis', host: 'localhost', port: 6379, username: '', password: '', options: {} } as any, + ] + + for (const ds of store.datasources) { + store.status[ds.id] = 'connected' + store.statusCheckedAt[ds.id] = Date.now() + } + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + expect(wrapper.findAll('.datasource-card')).toHaveLength(3) + + await wrapper.find('#datasource-sort').setValue('status') + await flushPromises() + + expect(wrapper.findAll('.datasource-card')).toHaveLength(3) + }) +}) diff --git a/frontend/src/__tests__/datasource-list-status-error.test.ts b/frontend/src/__tests__/datasource-list-status-error.test.ts new file mode 100644 index 0000000..3e0a42b --- /dev/null +++ b/frontend/src/__tests__/datasource-list-status-error.test.ts @@ -0,0 +1,489 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView status details', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + }) + + it('renders a fixed status row and copies full error text', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_fail', + name: 'Failing', + type: 'redis', + host: '127.0.0.1', + port: 6379, + username: '', + password: '', + options: {}, + }, + { + id: 'ds_ok', + name: 'Okay', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: '', + password: '', + database: 'main', + options: {}, + }, + ] + store.status['ds_fail'] = 'failed' + store.statusDetails['ds_fail'] = 'Timeout connecting to redis at 127.0.0.1:6379' + store.status['ds_ok'] = 'connected' + store.statusDetails['ds_ok'] = '' + + vi.spyOn(api, 'testDatasource').mockRejectedValue(new Error('Timeout connecting to redis at 127.0.0.1:6379')) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const rows = wrapper.findAll('[data-testid="datasource-status-detail-row"]') + expect(rows).toHaveLength(2) + + const copyButtons = wrapper.findAll('[data-testid="datasource-status-copy"]') + expect(copyButtons).toHaveLength(1) + + await copyButtons[0].trigger('click') + expect(writeText).toHaveBeenCalledWith('Timeout connecting to redis at 127.0.0.1:6379') + }) + + it('animates the existing status badge when user runs Test', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ok', + name: 'Okay', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + username: '', + password: '', + database: 'main', + options: {}, + }, + ] + store.status['ds_ok'] = 'connected' + store.statusDetails['ds_ok'] = '' + store.statusCheckedAt['ds_ok'] = Date.now() + + vi.spyOn(api, 'testDatasource').mockResolvedValue(true as any) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const testButton = wrapper.findAll('button').find((btn) => btn.text() === tApp('common.test')) + expect(testButton).toBeTruthy() + + await testButton!.trigger('click') + await flushPromises() + + const badge = wrapper.find('[data-testid="datasource-status-badge"][data-datasource-id="ds_ok"]') + expect(badge.exists()).toBe(true) + expect(badge.classes()).toContain('is-flash') + }) + + it('shows error notice when d1 re-authentication test still fails', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_fail', + name: 'D1 Failing', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'old_token', + }, + }, + ] + store.status['ds_d1_fail'] = 'failed' + store.statusDetails['ds_d1_fail'] = 'HTTP 401 unauthorized: token expired' + store.statusCheckedAt['ds_d1_fail'] = Date.now() + + vi.spyOn(api as any, 'd1OAuthReLogin').mockResolvedValue({ + token: 'new_token', + accountId: 'acc_current', + accounts: [{ id: 'acc_current', name: 'Current Account' }], + } as any) + vi.spyOn(api, 'updateDatasource').mockResolvedValue(true as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue(store.datasources as any) + vi.spyOn(api, 'testDatasource') + .mockRejectedValueOnce(new Error('HTTP 401 unauthorized: token expired')) + .mockRejectedValue(new Error('still unauthorized after re-auth')) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButton = wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.d1ReAuthentication')) + expect(reAuthButton).toBeTruthy() + + await reAuthButton!.trigger('click') + await flushPromises() + + expect(store.notice.type).toBe('error') + expect(store.notice.message).toContain('still unauthorized after re-auth') + expect(store.notice.message).not.toBe(tApp('datasource.list.d1ReAuthenticationSuccess')) + }) + + it('does not change datasource account during re-auth when oauth account list does not include current account', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_account_mismatch', + name: 'D1 Account Mismatch', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'old_token', + }, + }, + ] + store.status['ds_d1_account_mismatch'] = 'failed' + store.statusDetails['ds_d1_account_mismatch'] = 'HTTP 401 unauthorized: token expired' + store.statusCheckedAt['ds_d1_account_mismatch'] = Date.now() + + vi.spyOn(api as any, 'd1OAuthReLogin').mockResolvedValue({ + token: 'new_token', + accountId: 'acc_other', + accounts: [{ id: 'acc_other', name: 'Other Account' }], + } as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue(true as any) + vi.spyOn(api, 'testDatasource').mockRejectedValue(new Error('HTTP 401 unauthorized: token expired')) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButton = wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.d1ReAuthentication')) + expect(reAuthButton).toBeTruthy() + + await reAuthButton!.trigger('click') + await flushPromises() + + expect(updateSpy).not.toHaveBeenCalled() + expect(store.notice.type).toBe('error') + expect(store.notice.message).toBe(tApp('datasource.list.d1ReAuthenticationAccountMismatch')) + }) + + it('does not show d1 re-authentication button when d1 connection status is connected', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_connected', + name: 'D1 Connected', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'token_connected', + }, + }, + ] + store.status['ds_d1_connected'] = 'connected' + store.statusDetails['ds_d1_connected'] = '' + store.statusCheckedAt['ds_d1_connected'] = Date.now() + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButtons = wrapper.findAll('button').filter((btn) => btn.text() === tApp('datasource.list.d1ReAuthentication')) + expect(reAuthButtons).toHaveLength(0) + }) + + it('shows d1 re-authentication button for non-expiry authorization failures in token mode', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_scope_error', + name: 'D1 Scope Error', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'old_token', + }, + }, + ] + store.status['ds_d1_scope_error'] = 'failed' + store.statusDetails['ds_d1_scope_error'] = 'HTTP 403 forbidden: missing account scope for this token' + store.statusCheckedAt['ds_d1_scope_error'] = Date.now() + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButtons = wrapper.findAll('button').filter((btn) => btn.text() === tApp('datasource.list.d1ReAuthentication')) + expect(reAuthButtons).toHaveLength(1) + }) + + it('renders localized d1 re-authentication button label in zh locale', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_i18n_zh', + name: 'D1 Localized', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'token_i18n', + }, + }, + ] + store.status['ds_d1_i18n_zh'] = 'failed' + store.statusDetails['ds_d1_i18n_zh'] = 'HTTP 401 unauthorized: token expired' + store.statusCheckedAt['ds_d1_i18n_zh'] = Date.now() + + const enLabel = tApp('datasource.list.d1ReAuthentication') + setAppLocale('zh') + const zhLabel = tApp('datasource.list.d1ReAuthentication') + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButton = wrapper.findAll('button').find((btn) => btn.text() === zhLabel) + expect(reAuthButton).toBeTruthy() + expect(zhLabel).not.toBe(enLabel) + }) + + it('shows loading state on d1 re-authentication button while oauth relogin is in progress', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_d1_loading', + name: 'D1 Loading', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: 'analytics', + authSource: '', + options: { + authMode: 'token', + accountId: 'acc_current', + databaseId: 'db_analytics', + databaseName: 'analytics', + apiToken: 'old_token', + }, + }, + ] + store.status['ds_d1_loading'] = 'failed' + store.statusDetails['ds_d1_loading'] = 'HTTP 401 unauthorized: token expired' + store.statusCheckedAt['ds_d1_loading'] = Date.now() + + let resolveRelogin: ((value: any) => void) | null = null + const reloginPromise = new Promise((resolve) => { + resolveRelogin = resolve + }) + vi.spyOn(api as any, 'd1OAuthReLogin').mockReturnValue(reloginPromise as any) + vi.spyOn(api, 'updateDatasource').mockResolvedValue(true as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue(store.datasources as any) + vi.spyOn(api, 'testDatasource').mockRejectedValue(new Error('still unauthorized after re-auth')) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const findReAuthButton = () => wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.d1ReAuthentication')) + const reAuthButton = findReAuthButton() + expect(reAuthButton).toBeTruthy() + + await reAuthButton!.trigger('click') + await flushPromises() + + const loadingButton = wrapper.find('[data-testid="d1-reauth-button"][data-datasource-id="ds_d1_loading"]') + expect(loadingButton.exists()).toBe(true) + expect(loadingButton.attributes('disabled')).toBeDefined() + expect(loadingButton.classes()).toContain('is-loading') + + resolveRelogin?.({ + token: 'new_token', + accountId: 'acc_current', + accounts: [{ id: 'acc_current', name: 'Current Account' }], + }) + await flushPromises() + + const resetButton = wrapper.find('[data-testid="d1-reauth-button"][data-datasource-id="ds_d1_loading"]') + expect(resetButton.attributes('disabled')).toBeUndefined() + expect(resetButton.classes()).not.toContain('is-loading') + }) + + it('re-authenticates dynamodb sso datasource and refreshes credentials', async () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_ddb_sso', + name: 'DynamoDB SSO', + type: 'dynamodb', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + credentials: { + accessKeyId: 'AKIA_OLD', + secretAccessKey: 'SECRET_OLD', + sessionToken: 'SESSION_OLD', + }, + }, + } as any, + ] + store.status['ds_ddb_sso'] = 'failed' + store.statusDetails['ds_ddb_sso'] = 'ExpiredToken' + store.statusCheckedAt['ds_ddb_sso'] = Date.now() + + vi.spyOn(api as any, 'dynamoDBSSOOAuthAuthorize').mockResolvedValue({ + profile: 'default', + region: 'us-east-1', + accountId: '111111111111', + roleName: 'Admin', + accessKeyId: 'AKIA_NEW', + secretAccessKey: 'SECRET_NEW', + sessionToken: 'SESSION_NEW', + expiration: 1735689600000, + } as any) + const updateSpy = vi.spyOn(api, 'updateDatasource').mockResolvedValue(true as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue(store.datasources as any) + vi.spyOn(api, 'testDatasource') + .mockRejectedValueOnce(new Error('ExpiredToken')) + .mockResolvedValue(true as any) + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const reAuthButton = wrapper.find('[data-testid="dynamodb-reauth-button"][data-datasource-id="ds_ddb_sso"]') + expect(reAuthButton.exists()).toBe(true) + + await reAuthButton.trigger('click') + await flushPromises() + + expect(updateSpy).toHaveBeenCalledWith( + 'ds_ddb_sso', + expect.objectContaining({ + type: 'dynamodb', + options: expect.objectContaining({ + authMode: 'sso', + profile: 'default', + region: 'us-east-1', + ssoAccountId: '111111111111', + ssoRoleName: 'Admin', + ssoCredentialExpiration: 1735689600000, + credentials: { + accessKeyId: 'AKIA_NEW', + secretAccessKey: 'SECRET_NEW', + sessionToken: 'SESSION_NEW', + }, + }), + }), + ) + expect(store.notice.type).toBe('success') + expect(store.notice.message).toBe(tApp('datasource.list.dynamoReAuthenticationSuccess')) + }) +}) diff --git a/frontend/src/__tests__/datasource-list-type-class.test.ts b/frontend/src/__tests__/datasource-list-type-class.test.ts new file mode 100644 index 0000000..9c74e14 --- /dev/null +++ b/frontend/src/__tests__/datasource-list-type-class.test.ts @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceListView from '@/views/DatasourceListView.vue' +import { useAppStore } from '@/stores/app' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('DatasourceListView', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + }) + + it('normalizes datasource type classes for list labels', () => { + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis', + name: 'Redis Cluster', + type: 'redis-cluster', + host: 'localhost', + port: 6379, + username: '', + password: '', + options: {}, + }, + { + id: 'ds_unknown', + name: 'Mystery', + type: '', + host: 'localhost', + port: 0, + username: '', + password: '', + options: {}, + }, + ] + + const wrapper = mount(DatasourceListView, { + global: { + plugins: [pinia], + }, + }) + + const types = wrapper.findAll('.datasource-type') + const typeClassList = types.map((node) => node.classes()) + + expect(typeClassList.some((classes) => classes.includes('datasource-type--redis_cluster'))).toBe(true) + expect(typeClassList.some((classes) => classes.includes('datasource-type--unknown'))).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/datasource-plan-gating.test.ts b/frontend/src/__tests__/datasource-plan-gating.test.ts new file mode 100644 index 0000000..a2111ca --- /dev/null +++ b/frontend/src/__tests__/datasource-plan-gating.test.ts @@ -0,0 +1,191 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import DatasourceListView from '@/views/DatasourceListView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const pushMock = vi.fn() +const routeState: { name: string; params: Record; fullPath: string } = { + name: 'datasource-create', + params: {}, + fullPath: '/datasources/new', +} + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +describe('datasource plan gating', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + pushMock.mockReset() + routeState.name = 'datasource-create' + routeState.params = {} + routeState.fullPath = '/datasources/new' + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('blocks free users from opening datasource creation after reaching the limit', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + store.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_2', name: 'Two', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_3', name: 'Three', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + ] as any + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.new'))!.trigger('click') + + expect(pushMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('plan.notice.datasourceLimit', { plan: tApp('plan.name.free'), limit: 3 })) + }) + + it('still lets pro users enter datasource creation after three datasources', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + store.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_2', name: 'Two', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_3', name: 'Three', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + ] as any + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.new'))!.trigger('click') + + expect(pushMock).toHaveBeenCalledWith({ name: 'datasource-create' }) + }) + + it('blocks logged-out users from creating a fourth datasource', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_unknown', + session: null, + pendingLogin: null, + } as any + store.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_2', name: 'Two', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_3', name: 'Three', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + ] as any + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.new'))!.trigger('click') + + expect(pushMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('plan.notice.datasourceLimit', { plan: tApp('plan.name.free'), limit: 3 })) + }) + + it('treats unknown non-empty plan values like free to match backend gating', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_unknown_plan', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_unknown', email: 'unknown@example.com', displayName: 'Unknown Plan User', avatarUrl: '' }, + license: { plan: 'enterprise', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + store.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_2', name: 'Two', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_3', name: 'Three', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + ] as any + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.new'))!.trigger('click') + + expect(pushMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('plan.notice.datasourceLimit', { plan: tApp('plan.name.free'), limit: 3 })) + }) + + it('blocks datasource form save for free users who already have three datasources', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + store.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_2', name: 'Two', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + { id: 'ds_3', name: 'Three', type: 'mysql', host: '127.0.0.1', port: 3306, username: 'root', password: '', database: 'db', authSource: '', options: {} }, + ] as any + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_4' } as any) + + const wrapper = mount(DatasourceFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Blocked datasource') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('plan.notice.datasourceLimit', { plan: tApp('plan.name.free'), limit: 3 })) + }) +}) diff --git a/frontend/src/__tests__/embedding-config-form.test.ts b/frontend/src/__tests__/embedding-config-form.test.ts new file mode 100644 index 0000000..adc0149 --- /dev/null +++ b/frontend/src/__tests__/embedding-config-form.test.ts @@ -0,0 +1,84 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' + +import EmbeddingConfigForm from '@/components/EmbeddingConfigForm.vue' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('EmbeddingConfigForm', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listEmbeddingConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows a translated validation message when model is missing', async () => { + vi.spyOn(api, 'listEmbeddingProviders').mockResolvedValue({ + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: '', + models: [], + }, + } as any) + const createSpy = vi.spyOn(api, 'createEmbeddingConfig').mockResolvedValue({ id: 'emb_1' } as any) + + const wrapper = mount(EmbeddingConfigForm, { + props: { mode: 'create' }, + }) + await flushPromises() + + await wrapper.find('#emb-name').setValue('Embedding config') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('validation.modelRequired')) + expect(wrapper.text()).not.toContain('validation.modelRequired') + }) + + it('disables autocorrect helpers on user-editable embedding inputs', async () => { + vi.spyOn(api, 'listEmbeddingProviders').mockResolvedValue({ + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'text-embedding-3-small', + models: ['text-embedding-3-small'], + }, + custom: { + name: 'Custom', + baseUrl: '', + defaultModel: '', + models: [], + }, + } as any) + + const wrapper = mount(EmbeddingConfigForm, { + props: { mode: 'create' }, + }) + await flushPromises() + + for (const selector of ['#emb-name', '#emb-apikey']) { + const input = wrapper.get(selector) + expect(input.attributes('autocapitalize')).toBe('off') + expect(input.attributes('autocomplete')).toBe('off') + expect(input.attributes('autocorrect')).toBe('off') + expect(input.attributes('spellcheck')).toBe('false') + } + + await wrapper.get('#emb-provider').setValue('custom') + await flushPromises() + + const baseUrlInput = wrapper.get('#emb-baseurl') + expect(baseUrlInput.attributes('autocapitalize')).toBe('off') + expect(baseUrlInput.attributes('autocomplete')).toBe('off') + expect(baseUrlInput.attributes('autocorrect')).toBe('off') + expect(baseUrlInput.attributes('spellcheck')).toBe('false') + }) +}) diff --git a/frontend/src/__tests__/helpers/consoleEditor.ts b/frontend/src/__tests__/helpers/consoleEditor.ts new file mode 100644 index 0000000..ea0f818 --- /dev/null +++ b/frontend/src/__tests__/helpers/consoleEditor.ts @@ -0,0 +1,7 @@ +import type { VueWrapper } from '@vue/test-utils' + +export const getConsoleStatementInput = (wrapper: VueWrapper) => { + const legacyTextarea = wrapper.find('#statement-input') + if (legacyTextarea.exists()) return legacyTextarea + return wrapper.get('.console-monaco-editor__fallback') +} diff --git a/frontend/src/__tests__/helpers/read-css-with-imports.ts b/frontend/src/__tests__/helpers/read-css-with-imports.ts new file mode 100644 index 0000000..e0cb2c7 --- /dev/null +++ b/frontend/src/__tests__/helpers/read-css-with-imports.ts @@ -0,0 +1,38 @@ +import fs from 'node:fs' +import path from 'node:path' + +const importRe = /^\s*@import\s+(?:url\()?["']([^"']+)["']\)?\s*;\s*$/gm + +const shouldInlineImport = (specifier: string) => { + if (!specifier) return false + if (specifier === 'tailwindcss') return false + if (specifier.startsWith('http://') || specifier.startsWith('https://')) return false + if (specifier.startsWith('data:')) return false + return specifier.startsWith('.') || specifier.startsWith('/') +} + +export const readCssWithImports = (entryPath: string): string => { + const visited = new Set() + + const readFile = (filePath: string): string => { + const absolutePath = path.resolve(filePath) + if (visited.has(absolutePath)) return '' + visited.add(absolutePath) + + const dir = path.dirname(absolutePath) + const content = fs.readFileSync(absolutePath, 'utf-8') + + return content.replace(importRe, (fullMatch, specifier: string) => { + if (!shouldInlineImport(specifier)) return '' + const resolved = specifier.startsWith('/') + ? path.resolve(dir, '.' + specifier) + : path.resolve(dir, specifier) + if (!fs.existsSync(resolved)) { + return fullMatch + } + return readFile(resolved) + }) + } + + return readFile(entryPath) +} diff --git a/frontend/src/__tests__/helpers/select-datasource-type.ts b/frontend/src/__tests__/helpers/select-datasource-type.ts new file mode 100644 index 0000000..8ef2b54 --- /dev/null +++ b/frontend/src/__tests__/helpers/select-datasource-type.ts @@ -0,0 +1,16 @@ +import type { VueWrapper } from '@vue/test-utils' +import { flushPromises } from '@vue/test-utils' + +export const selectDatasourceType = async (wrapper: VueWrapper, label: string) => { + await wrapper.find('#ds-type').trigger('click') + await flushPromises() + + const options = wrapper.findAll('.ds-type-select-option') + const target = options.find((option) => option.text().includes(label)) + if (!target) { + throw new Error(`Datasource type option not found: ${label}`) + } + + await target.trigger('click') + await flushPromises() +} diff --git a/frontend/src/__tests__/history-api.test.ts b/frontend/src/__tests__/history-api.test.ts new file mode 100644 index 0000000..84902b9 --- /dev/null +++ b/frontend/src/__tests__/history-api.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { api } from '@/services/api' + +describe('history api', () => { + it('exposes listHistory and appendHistory', () => { + expect(typeof api.listHistory).toBe('function') + expect(typeof api.appendHistory).toBe('function') + }) + + it('exposes history helpers', () => { + expect(typeof api.getHistory).toBe('function') + expect(typeof api.deleteHistory).toBe('function') + expect(typeof api.clearHistory).toBe('function') + }) +}) diff --git a/frontend/src/__tests__/history-entry-type.test.ts b/frontend/src/__tests__/history-entry-type.test.ts new file mode 100644 index 0000000..93e8e5d --- /dev/null +++ b/frontend/src/__tests__/history-entry-type.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import type { HistoryEntry } from '@/types' + +describe('HistoryEntry type', () => { + it('accepts tags and targets', () => { + const entry: HistoryEntry = { + id: 'h1', + statement: 'SELECT 1', + executedAt: '2026-01-01T00:00:00Z', + datasourceId: 'ds', + datasourceName: 'DS', + datasourceType: 'mysql', + database: '', + targets: ['t'], + tags: ['DS', 'mysql', 't'], + } + + expect(entry.targets[0]).toBe('t') + }) +}) diff --git a/frontend/src/__tests__/history-view.test.ts b/frontend/src/__tests__/history-view.test.ts new file mode 100644 index 0000000..c5b6ab4 --- /dev/null +++ b/frontend/src/__tests__/history-view.test.ts @@ -0,0 +1,540 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import HistoryView from '@/views/HistoryView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import type { AgentAuditEntry, HistoryEntry } from '@/types' + +const pushMock = vi.fn() + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'history', query: { datasourceId: 'ds_1', target: 'orders', database: 'main' } }), + useRouter: () => ({ push: pushMock }), +})) + +const historyEntry = (overrides: Partial = {}): HistoryEntry => ({ + id: 'h1', + statement: 'SELECT 1', + executedAt: '2024-01-01T00:00:00Z', + datasourceId: 'ds_1', + datasourceName: 'Primary', + datasourceType: 'mysql', + database: 'main', + targets: ['orders'], + tags: [], + ...overrides, +}) + +const mockListHistory = (entries: HistoryEntry[]) => vi.spyOn(api, 'listHistory').mockResolvedValue(entries) +const agentAuditEntry = (overrides: Partial = {}): AgentAuditEntry => ({ + id: 'a1', + accessKey: 'agent_1234', + agentName: 'agent-1234', + protocol: 'skill', + toolName: 'execute_statement', + summary: 'SELECT * FROM users', + statement: 'SELECT id, email\nFROM users\nORDER BY id DESC\nLIMIT 50', + datasourceId: 'ds_1', + datasourceName: 'Primary', + datasourceType: 'mysql', + target: 'users', + status: 'success', + executedAt: '2024-01-01T00:00:00Z', + ...overrides, +}) + +describe('HistoryView', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listAgentIdentities').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('applies datasource type classes to tags and filter pills', async () => { + mockListHistory([historyEntry({ statement: 'SELECT * FROM orders' })]) + + const store = useAppStore() + store.datasources = [{ id: 'ds_1', name: 'Primary', type: 'mongodb', host: 'localhost', port: 27017, username: '', password: '', options: {} } as any] + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const datasourceTag = wrapper.find('.history-tag--datasource') + const typeTag = wrapper.find('.history-tag--type') + const datasourcePill = wrapper.find('.history-filter-pills .history-pill') + + expect(datasourceTag.classes()).toContain('datasource-type--mysql') + expect(typeTag.classes()).toContain('datasource-type--mysql') + expect(datasourcePill.text()).toContain('Datasource:') + expect(datasourcePill.classes()).toContain('datasource-type--mongodb') + }) + + it('marks history delete as danger', async () => { + mockListHistory([historyEntry()]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="history-delete"]').classes()).toContain('danger') + }) + + it('loads history with filters and keyword', async () => { + const listSpy = mockListHistory([historyEntry({ statement: 'SELECT * FROM orders', tags: ['Primary', 'mysql', 'orders'] })]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + datasourceId: 'ds_1', + target: 'orders', + database: 'main', + }), + ) + expect(wrapper.text()).toContain('SELECT * FROM orders') + + await wrapper.find('#history-search').setValue('orders') + await flushPromises() + + expect(listSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + keyword: 'orders', + }), + ) + }) + + it('skips db tag for redis history entries', async () => { + mockListHistory([historyEntry({ statement: 'GET key', datasourceId: 'ds_redis', datasourceName: 'Redis', datasourceType: 'redis', database: '0', targets: ['key'] })]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('.history-tag--db').exists()).toBe(false) + }) + + it('skips db tag for elasticsearch history entries', async () => { + mockListHistory([ + historyEntry({ + statement: 'POST /orders/_search {}', + datasourceId: 'ds_es', + datasourceName: 'ES', + datasourceType: 'elasticsearch', + database: 'mysql', + targets: ['orders'], + }), + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('.history-tag--db').exists()).toBe(false) + expect(wrapper.text()).not.toContain('db: mysql') + }) + it('navigates to console on statement click', async () => { + mockListHistory([historyEntry()]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('.history-statement').trigger('click') + + expect(pushMock).toHaveBeenCalledWith({ + name: 'console', + params: { id: 'ds_1' }, + query: { historyId: 'h1' }, + }) + }) + + it('deletes entries and clears filtered history', async () => { + mockListHistory([historyEntry()]) + const deleteSpy = vi.spyOn(api, 'deleteHistory').mockResolvedValue(true) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-delete"]').trigger('click') + + expect(deleteSpy).toHaveBeenCalledWith('h1') + }) + + it('requires confirmation before clearing filtered history', async () => { + mockListHistory([historyEntry()]) + const clearSpy = vi.spyOn(api, 'clearHistory').mockResolvedValue(1) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-clear-filtered"]').trigger('click') + + expect(clearSpy).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="history-clear-confirm-dialog"]').exists()).toBe(true) + + await wrapper.find('[data-testid="history-clear-confirm"]').trigger('click') + await flushPromises() + + expect(clearSpy).toHaveBeenCalledWith( + expect.objectContaining({ + datasourceId: 'ds_1', + target: 'orders', + database: 'main', + }), + ) + }) + + it('adds datasource tag class by type', async () => { + mockListHistory([historyEntry()]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const datasourceTag = wrapper.find('.history-tag--datasource') + expect(datasourceTag.classes()).toContain('datasource-type--mysql') + }) + + it('handles entries with missing targets', async () => { + mockListHistory([historyEntry({ targets: undefined as unknown as string[] })]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('SELECT 1') + }) + + it('renders one card per agent audit entry with agent name in identity row', async () => { + mockListHistory([]) + const listAgentAuditSpy = vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry(), + agentAuditEntry({ id: 'a2', protocol: 'mcp', summary: 'describe users', toolName: 'describe_entity', agentName: 'warehouse-bot', accessKey: 'agent_9876' }), + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + expect(listAgentAuditSpy).toHaveBeenLastCalledWith({ keyword: '', limit: 200 }) + + // The agent name now lives in each entry's identity row, not a group header. + const identityRows = wrapper.findAll('[data-testid="history-agent-entry-identity"]') + expect(identityRows).toHaveLength(2) + expect(identityRows[0].text()).toContain('agent-1234') + expect(identityRows[1].text()).toContain('warehouse-bot') + + // Each entry still shows its tool name and the per-entry protocol pill. + expect(wrapper.findAll('.history-agent-entry__tool').map((node) => node.text())) + .toEqual(['execute_statement', 'describe_entity']) + expect(identityRows[0].find('.history-agent-protocol').text()).toBe('Skill') + expect(identityRows[1].find('.history-agent-protocol').text()).toBe('MCP') + + // execute_statement: summary/target are duplicates of statement, so they + // are suppressed; only the full statement should be rendered. + const summaries = wrapper.findAll('.history-agent-entry__summary').map((node) => node.text()) + expect(summaries).not.toContain('SELECT * FROM users') + expect(wrapper.text()).toContain('SELECT id, email') + // describe_entity: summary still shown because it carries distinct info. + expect(summaries).toContain('describe users') + expect(wrapper.find('[data-testid="history-agent-rename-btn"]').exists()).toBe(false) + }) + + it('renders rejection reason for non-success agent audit entries', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry({ + id: 'a-rej', + status: 'error', + message: 'datasource is read-only', + }), + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + const rejection = wrapper.find('[data-testid="history-agent-entry-rejection"]') + expect(rejection.exists()).toBe(true) + expect(rejection.text()).toContain('datasource is read-only') + }) + + it('renders risk attribution from risk_engine source with rule link', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry({ + id: 'a-risk-rule', + status: 'approval_required', + riskAttribution: { + source: 'risk_engine', + action: 'require_approval', + level: 'high', + ruleId: 'rule_delete', + ruleCode: 'delete_full_table', + ruleDescription: 'DELETE without WHERE', + builtin: true, + reasons: ['DELETE without WHERE on users'], + }, + }), + ]) + + const wrapper = mount(HistoryView, { + global: { plugins: [pinia] }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + const risk = wrapper.find('[data-testid="history-agent-entry-risk"]') + expect(risk.exists()).toBe(true) + expect(risk.text()).toContain('DELETE without WHERE') + expect(risk.text()).toContain('DELETE without WHERE on users') + + const action = wrapper.find('[data-testid="history-agent-entry-risk-action"]') + expect(action.exists()).toBe(true) + expect(action.classes()).toContain('history-agent-entry__risk-action--require_approval') + + const link = wrapper.find('[data-testid="history-agent-entry-risk-rule-link"]') + expect(link.exists()).toBe(true) + pushMock.mockClear() + await link.trigger('click') + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules', query: { highlight: 'rule_delete', source: 'builtin' } }) + }) + + it('renders risk attribution from policy source without rule link', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry({ + id: 'a-risk-pol', + toolName: 'create_datasource', + status: 'approval_required', + riskAttribution: { + source: 'policy', + action: 'require_approval', + }, + }), + ]) + + const wrapper = mount(HistoryView, { + global: { plugins: [pinia] }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + const risk = wrapper.find('[data-testid="history-agent-entry-risk"]') + expect(risk.exists()).toBe(true) + // policy source: render the system-policy label, not a clickable rule link + expect(wrapper.find('[data-testid="history-agent-entry-risk-rule-link"]').exists()).toBe(false) + expect(risk.text()).toMatch(/(System policy|系统内置策略)/) + }) + + it('omits risk attribution panel when entry has no riskAttribution', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([agentAuditEntry()]) + + const wrapper = mount(HistoryView, { + global: { plugins: [pinia] }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="history-agent-entry-risk"]').exists()).toBe(false) + }) + + it('shows a revoked badge when the agent identity is revoked', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([agentAuditEntry()]) + vi.spyOn(api, 'listAgentIdentities').mockResolvedValue([ + { + accessKey: 'agent_1234', + name: 'agent-1234', + agentType: 'claude', + source: 'detected', + revokedAt: '2026-04-23T10:00:00Z', + createdAt: '2026-04-22T10:00:00Z', + updatedAt: '2026-04-23T10:00:00Z', + }, + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="history-agent-revoked-badge"]').exists()).toBe(true) + }) + + it('filters agent audit by agent via the dropdown', async () => { + mockListHistory([]) + const listAgentAuditSpy = vi.spyOn(api, 'listAgentAudit').mockResolvedValue([agentAuditEntry()]) + vi.spyOn(api, 'listAgentIdentities').mockResolvedValue([ + { + accessKey: 'agent_1234', + name: 'agent-1234', + agentType: 'claude', + source: 'detected', + createdAt: '', + updatedAt: '', + }, + { + accessKey: 'agent_9876', + name: 'warehouse-bot', + agentType: 'cursor', + source: 'detected', + createdAt: '', + updatedAt: '', + }, + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + const select = wrapper.find('[data-testid="history-agent-filter"]') + expect(select.exists()).toBe(true) + await (select.element as HTMLSelectElement & { value: string }).value !== undefined + await select.setValue('agent_9876') + await flushPromises() + + expect(listAgentAuditSpy).toHaveBeenLastCalledWith({ keyword: '', limit: 200, accessKey: 'agent_9876' }) + }) + + it('shows localized fallback when agent identity is missing', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry({ agentName: '' }), + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Unknown agent') + }) + + it('passes bounded keyword searches to agent audit loading', async () => { + mockListHistory([]) + const listAgentAuditSpy = vi.spyOn(api, 'listAgentAudit').mockResolvedValue([agentAuditEntry()]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + const keywordInput = wrapper.find('[data-testid="history-search-input"]') + await keywordInput.setValue('warehouse') + await flushPromises() + + expect(listAgentAuditSpy).toHaveBeenLastCalledWith({ keyword: 'warehouse', limit: 200 }) + }) + + it('shows full agent audit statement when available', async () => { + mockListHistory([]) + vi.spyOn(api, 'listAgentAudit').mockResolvedValue([ + agentAuditEntry({ statement: 'SELECT *\nFROM users\nWHERE id = 7' }), + ]) + + const wrapper = mount(HistoryView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + await wrapper.find('[data-testid="history-tab-agent-audit"]').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Statement') + expect(wrapper.text()).toContain('SELECT *') + expect(wrapper.text()).toContain('WHERE id = 7') + }) +}) diff --git a/frontend/src/__tests__/i18n-static-wording-coverage.test.ts b/frontend/src/__tests__/i18n-static-wording-coverage.test.ts new file mode 100644 index 0000000..56c36ed --- /dev/null +++ b/frontend/src/__tests__/i18n-static-wording-coverage.test.ts @@ -0,0 +1,311 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +const projectRoot = path.resolve(__dirname, '..') + +const readSource = (relativePath: string) => + fs.readFileSync(path.join(projectRoot, relativePath), 'utf8') + +describe('static wording i18n coverage', () => { + it('replaces hardcoded wording in core views/components with i18n keys', () => { + const checks: Array<{ file: string; forbidden: string[] }> = [ + { + file: 'views/DatasourceListView.vue', + forbidden: [ + 'Data Sources', + 'Manage connections and jump into the console.', + 'Test All', + 'New Data Source', + 'Open Console', + 'Delete datasource', + ], + }, + { + file: 'views/datasource-list/useDatasourceListView.ts', + forbidden: [ + "'Connected'", + "'Failed'", + "'Testing'", + "'Unknown'", + "'Copied.'", + "'Datasource deleted.'", + "'No endpoint to copy.'", + ], + }, + { + file: 'views/DatasourceFormView.vue', + forbidden: [ + 'Configure connection details and test connectivity.', + '>Cancel<', + '>Save<', + 'Quick install (Docker)', + 'Test Connection', + ], + }, + { + file: 'views/datasource-form/useDatasourceFormView.ts', + forbidden: [ + "'Name is required.'", + "'Type is required.'", + "'Host is required.'", + "'Port is required.'", + "'Options must be valid JSON.'", + "'Connected'", + "'Failed'", + ], + }, + { + file: 'views/HistoryView.vue', + forbidden: [ + '

History

', + 'Review executed statements across datasources.', + 'Clear Filtered', + 'Clear Filters', + 'Loading history...', + 'No history yet.', + ], + }, + { + file: 'views/history/useHistoryView.ts', + forbidden: [ + "label: 'Datasource'", + "label: 'Target'", + "label: 'Database'", + "'Targets'", + "'Target'", + "'Cleared {removed} entries.'", + ], + }, + { + file: 'views/VisualizationView.vue', + forbidden: [ + '

Visualization

', + 'Render AI-generated visualizations with Vega-Lite, ECharts, and Three.js.', + 'Clear History', + '

History

', + 'Unsupported renderer', + ], + }, + { + file: 'components/AIConfigPanel.vue', + forbidden: [ + '

AI Settings

', + 'Manage providers for copilots and assistants.', + 'Add Provider', + 'Needs attention', + 'Delete AI provider', + ], + }, + { + file: 'components/useAIConfigPanel.ts', + forbidden: [ + "'Connected'", + "'Failed'", + "'Testing...'", + "'AI provider deleted'", + ], + }, + { + file: 'components/AIConfigForm.vue', + forbidden: [ + 'Set credentials, endpoints, and models.', + 'Configuration Name', + '>Provider<', + 'Test Connection', + 'API key is required.', + ], + }, + { + file: 'views/console/components/ConsoleToolbar.vue', + forbidden: [ + '

Console

', + '>Back<', + 'Switch Datasource', + 'Refresh Entities', + ], + }, + { + file: 'views/console/composables/useConsoleViewLabels.ts', + forbidden: [ + "'Select a datasource to begin.'", + "'No entities found.'", + "'Filter applies locally.'", + ], + }, + { + file: 'views/console/components/ConsoleEntitiesPanel.vue', + forbidden: [ + '>Refresh<', + '>Databases<', + 'Refresh Entities', + 'Loading details...', + 'No details available.', + ], + }, + { + file: 'views/console/components/ConsoleStatementPanel.vue', + forbidden: [ + 'aria-label="Statement tabs"', + 'New statement tab', + 'Type a statement to execute', + '>Execute<', + '>Execute All<', + '>Beautify<', + 'Current target', + 'Analyze (Postgres)', + ], + }, + { + file: 'views/console/components/ConsoleResultsContent.vue', + forbidden: [ + '

Result 1

', + '>All fields<', + '>Export<', + 'Filter results...', + '>Page size<', + 'No results yet.', + '>Copy JSON<', + ], + }, + { + file: 'views/console/components/ConsoleDangerDialogs.vue', + forbidden: [ + 'Confirm Redis command', + 'This command may block Redis or affect availability.', + 'High risk', + 'Run anyway', + ], + }, + { + file: 'views/console/components/ConsoleResultsPanel.vue', + forbidden: [ + '

Results

', + 'Expanded view', + '>Close<', + ], + }, + { + file: 'views/console/components/RedisKeyInspector.vue', + forbidden: [ + 'Key Inspector', + 'New Key', + 'Copy Key', + 'Clear output', + 'No preview items.', + 'Command Output', + ], + }, + { + file: 'views/console/components/ConsoleVisualizationBuilder.vue', + forbidden: [ + 'Choose chart settings, then open in Visualization.', + 'No simple fields available for visualization.', + 'Open Visualization', + ], + }, + { + file: 'components/VirtualTable.vue', + forbidden: [ + '>Copy<', + 'aria-label="Copy row"', + '0 rows.', + ], + }, + { + file: 'components/VirtualMongoList.vue', + forbidden: [ + 'more fields', + 'Copy document', + 'Document structure', + 'No document details.', + 'Raw JSON', + '0 documents.', + ], + }, + { + file: 'components/ThemeToggle.vue', + forbidden: [ + 'Switch to', + ], + }, + { + file: 'components/ai/AiSidebar.vue', + forbidden: [ + 'AI Chat', + 'New chat', + 'Approval required', + 'Reject', + 'Approve', + 'Voice input', + ], + }, + { + file: 'components/ai/AiQuickPrompt.vue', + forbidden: [ + 'Remove context', + 'Ask AI...', + 'aria-label="Send"', + ], + }, + { + file: 'components/ai/AiChatPreferences.vue', + forbidden: [ + 'AI Chat Preferences', + 'Default open', + 'Conversation retention', + ], + }, + { + file: 'views/ConsoleView.vue', + forbidden: [ + 'Resize entities and editor panels', + 'Resize editor and results panels', + ], + }, + { + file: 'components/ConsoleMonacoEditor.vue', + forbidden: [ + "label: 'Format statement'", + ], + }, + { + file: 'views/console/composables/useConsoleView.ts', + forbidden: [ + "'Use db..(...) for beautify.'", + "'Add arguments before beautify.'", + "'Invalid Mongo statement. Fix syntax before beautify.'", + "'Failed to beautify SQL.'", + ], + }, + { + file: 'views/console/composables/useConsoleResults.ts', + forbidden: [ + "'Row copied.'", + "'Page copied.'", + "'Mongo results copied.'", + ], + }, + { + file: 'views/console/composables/useConsoleLifecycle.ts', + forbidden: [ + "'No datasources available.'", + ], + }, + { + file: 'views/console/composables/useConsoleHistory.ts', + forbidden: [ + "'History entry does not match current datasource.'", + ], + }, + ] + + checks.forEach(({ file, forbidden }) => { + const source = readSource(file) + forbidden.forEach((literal) => { + expect(source, `${file} still contains: ${literal}`).not.toContain(literal) + }) + }) + }) +}) diff --git a/frontend/src/__tests__/json-code-highlight.test.ts b/frontend/src/__tests__/json-code-highlight.test.ts new file mode 100644 index 0000000..ea8b364 --- /dev/null +++ b/frontend/src/__tests__/json-code-highlight.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import { buildJsonCodeHighlightHtml, formatJsonCodePanelDraft } from '@/views/console/utils/jsonCodeHighlight' + +describe('json code highlight', () => { + it('highlights json keys, strings, numbers, literals, and braces', () => { + const html = buildJsonCodeHighlightHtml('{\n "query": {\n "size": 10,\n "active": true,\n "label": "ok",\n "fallback": null\n }\n}') + + expect(html).toContain('elastic-dsl-json-token-brace') + expect(html).toContain('elastic-dsl-json-token-key') + expect(html).toContain('"query"') + expect(html).toContain('elastic-dsl-json-token-number') + expect(html).toContain('>10<') + expect(html).toContain('elastic-dsl-json-token-literal') + expect(html).toContain('>true<') + expect(html).toContain('>null<') + expect(html).toContain('elastic-dsl-json-token-string') + expect(html).toContain('"ok"') + }) + + it('keeps incomplete json editable while still escaping raw text safely', () => { + const html = buildJsonCodeHighlightHtml('{\n "query": "oops\n}') + + expect(html).toContain('elastic-dsl-json-token-string') + expect(html).toContain('"oops') + expect(html).not.toContain(' { + const formatted = formatJsonCodePanelDraft({ + query: { + bool: { + filter: [ + { + terms: { + tag: ['seed', 'doc'], + }, + }, + { + bool: { + should: [ + { match: { message: 'seed' } }, + { match: { message: 'doc' } }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }) + + expect(formatted).toContain('"tag": ["seed", "doc"]') + expect(formatted).toContain('"should": [\n') + }) +}) diff --git a/frontend/src/__tests__/layout-ui-regression-css.test.ts b/frontend/src/__tests__/layout-ui-regression-css.test.ts new file mode 100644 index 0000000..9fb094c --- /dev/null +++ b/frontend/src/__tests__/layout-ui-regression-css.test.ts @@ -0,0 +1,873 @@ +import path from 'node:path' +import fs from 'node:fs' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const css = readCssWithImports(path.resolve(__dirname, '..', 'style.css')) + +describe('layout and form css regressions', () => { + it('keeps checkbox controls compact for inline labels', () => { + const checkbox = css.match(/input\[type="checkbox"\][\s\S]*?\}/)?.[0] ?? '' + + expect(checkbox).toMatch(/width:\s*16px/i) + expect(checkbox).toMatch(/height:\s*16px/i) + expect(checkbox).toMatch(/min-height:\s*16px/i) + }) + + it('keeps console entity actions at a usable tap target size', () => { + const toggle = css.match(/\.entity-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const parityButtons = css.match( + /\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.mini,\s*\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.small\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(toggle).toMatch(/width:\s*32px/i) + expect(toggle).toMatch(/height:\s*32px/i) + expect(parityButtons).toMatch(/min-height:\s*32px/i) + expect(parityButtons).toMatch(/height:\s*32px/i) + }) + + it('keeps parity entity panel header controls on a single scrollable row at narrow console widths', () => { + const narrowHeader = css.match( + /@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head\s*\{[\s\S]*?flex-direction:\s*column[\s\S]*?align-items:\s*stretch[\s\S]*?\}/i, + )?.[0] ?? '' + const narrowActions = css.match( + /@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.panel-head-actions\s*\{[\s\S]*?width:\s*100%[\s\S]*?flex-wrap:\s*nowrap[\s\S]*?overflow-x:\s*auto[\s\S]*?\}/i, + )?.[0] ?? '' + const narrowButtons = css.match( + /@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.mini,\s*\.console-shell\.sql-editor-parity\s+\.console-panel--entities\s+\.btn\.ghost\.small\s*\{[\s\S]*?flex:\s*0\s+0\s+auto[\s\S]*?white-space:\s*nowrap[\s\S]*?\}/i, + )?.[0] ?? '' + + expect(narrowHeader).not.toBe('') + expect(narrowActions).not.toBe('') + expect(narrowButtons).not.toBe('') + }) + + it('adds dedicated chromadb stitch entity styling for collection metadata', () => { + const chromaEntityItem = css.match( + /\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.entity-item\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const chromaBadge = css.match( + /\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.chroma-collection-badge\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const chromaMeta = css.match( + /\.console-shell\.sql-editor-parity\.chroma-stitch\s+\.console-panel--entities\s+\.chroma-collection-inline\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + + expect(chromaEntityItem).toMatch(/min-height:\s*32px/i) + expect(chromaBadge).toMatch(/border-radius:\s*999px/i) + expect(chromaMeta).toMatch(/display:\s*inline-flex/i) + expect(chromaMeta).toMatch(/flex-wrap:\s*wrap/i) + }) + + it('prevents sql-editor toolbar controls from shrinking wrapped text', () => { + const toolbarButtons = css.match(/\.editor-toolbar-sql-editor\s+\.toolbar-left\s+button[\s\S]*?\}/)?.[0] ?? '' + const analyzeToggle = css.match(/\.editor-toolbar-sql-editor\s+\.analyze-toggle-sql-editor[\s\S]*?\}/)?.[0] ?? '' + + expect(toolbarButtons).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(toolbarButtons).toMatch(/white-space:\s*nowrap/i) + expect(analyzeToggle).toMatch(/flex:\s*0\s+0\s+auto/i) + }) + + it('renders dynamodb limit controls as a popover trigger that fits the toolbar', () => { + const controls = css.match(/\.dynamo-limit-controls\s*\{[\s\S]*?\}/)?.[0] ?? '' + const trigger = css.match(/\.dynamo-limit-trigger\s*\{[\s\S]*?\}/)?.[0] ?? '' + const popover = css.match(/\.dynamo-limit-popover\s*\{[\s\S]*?\}/)?.[0] ?? '' + const field = css.match(/\.dynamo-limit-field\s*\{[\s\S]*?\}/)?.[0] ?? '' + const input = css.match(/\.dynamo-limit-field\s+input\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(controls).toMatch(/position:\s*relative/i) + expect(controls).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(trigger).toMatch(/height:\s*32px/i) + expect(trigger).toMatch(/white-space:\s*nowrap/i) + expect(popover).toMatch(/position:\s*fixed/i) + expect(popover).toMatch(/var\(--popover/i) + expect(field).toMatch(/flex-direction:\s*column/i) + expect(input).toMatch(/var\(--input-bg|var\(--background/i) + }) + + it('keeps sql parity entity splitter visible under responsive console rules', () => { + const splitter = css.match(/\.console-shell\.sql-editor-parity\s+\.console-splitter\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(splitter).toMatch(/display:\s*block/i) + expect(splitter).toMatch(/cursor:\s*col-resize/i) + }) + + it('keeps ai sidebar docked at medium widths and only stacks on compact screens', () => { + const quickPromptLayoutPath = path.resolve(__dirname, '..', 'styles', 'ai-chat', 'quick-prompt-layout.css') + const quickPromptCss = fs.readFileSync(quickPromptLayoutPath, 'utf8') + const legacyMediumStack = quickPromptCss.match(/@media\s*\(max-width:\s*1100px\)\s*\{\s*\.app-shell-grid/i)?.[0] ?? '' + const compactStack = quickPromptCss.match(/@media\s*\(max-width:\s*760px\)\s*\{\s*\.app-shell-grid[\s\S]*?\.app-ai[\s\S]*?grid-row:\s*3/i)?.[0] ?? '' + + expect(legacyMediumStack).toBe('') + expect(compactStack).not.toBe('') + }) + + it('keeps title bar window controls at 32px tap targets', () => { + const titleBarPath = path.resolve(__dirname, '..', 'components', 'TitleBar.vue') + const titleBarSource = fs.readFileSync(titleBarPath, 'utf8') + const windowControl = titleBarSource.match(/\.window-control\s*\{[\s\S]*?\}/i)?.[0] ?? '' + + expect(windowControl).toMatch(/width:\s*32px/i) + expect(windowControl).toMatch(/height:\s*32px/i) + }) + + it('shrinks ai rail and sql parity entity pane before the compact stack breakpoint', () => { + const quickPromptLayoutPath = path.resolve(__dirname, '..', 'styles', 'ai-chat', 'quick-prompt-layout.css') + const quickPromptCss = fs.readFileSync(quickPromptLayoutPath, 'utf8') + const mediumAi = quickPromptCss.match(/@media\s*\(max-width:\s*980px\)\s*\{[\s\S]*?--ai-width:\s*clamp\(200px,\s*27vw,\s*260px\)/i)?.[0] ?? '' + const mediumNav = quickPromptCss.match(/@media\s*\(max-width:\s*980px\)\s*\{[\s\S]*?--nav-width:\s*clamp\(170px,\s*18vw,\s*190px\)/i)?.[0] ?? '' + const narrowAi = quickPromptCss.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?--ai-width:\s*clamp\(160px,\s*23vw,\s*196px\)/i)?.[0] ?? '' + const mediumConsole = css.match(/@media\s*\(max-width:\s*1080px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity[\s\S]*?min\(var\(--console-left,\s*236px\),\s*200px\)/i)?.[0] ?? '' + const narrowConsole = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-shell\.sql-editor-parity[\s\S]*?min\(var\(--console-left,\s*210px\),\s*150px\)/i)?.[0] ?? '' + + expect(mediumAi).not.toBe('') + expect(mediumNav).not.toBe('') + expect(narrowAi).not.toBe('') + expect(mediumConsole).not.toBe('') + expect(narrowConsole).not.toBe('') + }) + + it('keeps modal actions reachable when datasource lists grow long', () => { + const dialogCard = css.match(/\.dialog-card--scrollable[\s\S]*?\}/)?.[0] ?? '' + const dialogScroll = css.match(/\.dialog-scroll[\s\S]*?\}/)?.[0] ?? '' + + expect(dialogCard).toMatch(/max-height:\s*calc\(100vh\s*-\s*32px\)/i) + expect(dialogCard).toMatch(/display:\s*flex/i) + expect(dialogCard).toMatch(/flex-direction:\s*column/i) + expect(dialogCard).toMatch(/overflow:\s*hidden/i) + expect(dialogScroll).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(dialogScroll).toMatch(/min-height:\s*0/i) + expect(dialogScroll).toMatch(/overflow-y:\s*auto/i) + }) + + it('stabilizes sql parity results layout with multi-result tabs', () => { + const parityBase = css.match( + /(?:^|\n)\.console-results-content--sql-editor\s*\{[\s\S]*?display:\s*grid[\s\S]*?\}/, + )?.[0] ?? '' + const parityWithTabs = css.match( + /\.console-results-content--sql-editor\.console-results-content--sql-editor-with-tabs\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(parityBase).toMatch(/grid-template-rows:\s*auto\s+minmax\(0,\s*1fr\)\s+auto/i) + expect(parityWithTabs).toMatch(/grid-template-rows:\s*auto\s+auto\s+minmax\(0,\s*1fr\)\s+auto/i) + }) + + it('keeps sql parity result tabs on a single horizontal row with active emphasis', () => { + // Direction A (TASK-20260513-195708): result tabs moved from Chrome-style + // top-border + filled-box to a 2px primary underline indicator rendered + // via ::after at bottom: -1px. Same horizontal scroll behaviour preserved. + const tabs = css.match(/\.console-results-content--sql-editor\s+\.result-tabs\s*\{[\s\S]*?\}/)?.[0] ?? '' + const tabActive = css.match(/\.console-results-content--sql-editor\s+\.result-tab\.active\s*\{[\s\S]*?\}/)?.[0] ?? '' + const tabActiveAfter = css.match(/\.console-results-content--sql-editor\s+\.result-tab\.active::after\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(tabs).toMatch(/flex-wrap:\s*nowrap/i) + expect(tabs).toMatch(/overflow-x:\s*auto/i) + expect(tabActive).toMatch(/color:\s*var\(--primary\)/i) + expect(tabActiveAfter).toMatch(/height:\s*2px/i) + expect(tabActiveAfter).toMatch(/background:\s*currentColor/i) + }) + + it('reuses sql tab chrome for redis session tabs while preserving horizontal overflow', () => { + const redisTabListOverride = css.match( + /\.redis-session-tabs-shell\s+\.statement-tabs-list\s*\{[\s\S]*?overflow-x:\s*auto[\s\S]*?\}/, + )?.[0] ?? '' + + expect(css).toMatch(/\.redis-session-tabs-shell\s+\.statement-tabs\b/i) + expect(css).toMatch(/\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\b/i) + expect(css).toMatch(/\.redis-session-tabs-shell\s+\.statement-tab-add--sql-editor\b/i) + expect(redisTabListOverride).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(redisTabListOverride).toMatch(/overflow-x:\s*auto/i) + }) + + it('keeps sql parity result filter actions on one row without wrapped labels', () => { + const actions = css.match(/\.result-actions-sql-editor\s*\{[\s\S]*?\}/)?.[0] ?? '' + const actionButton = css.match(/\.result-actions-sql-editor\s+button\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(actions).toMatch(/flex-wrap:\s*nowrap/i) + expect(actions).toMatch(/overflow-x:\s*auto/i) + expect(actionButton).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(actionButton).toMatch(/white-space:\s*nowrap/i) + }) + + it('keeps stitch-like filter popover anchor and active trigger styles', () => { + const triggerActive = css.match(/\.result-filter-trigger\.is-active\s*\{[\s\S]*?\}/)?.[0] ?? '' + const anchor = css.match(/\.result-filter-anchor\s*\{[\s\S]*?\}/)?.[0] ?? '' + const popover = css.match(/\.result-filter-popover\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(triggerActive).toMatch(/border-color:\s*color-mix/i) + expect(triggerActive).toMatch(/box-shadow:\s*0\s+0\s+0\s+2px/i) + expect(anchor).toMatch(/position:\s*relative/i) + expect(popover).toMatch(/position:\s*fixed/i) + expect(popover).toMatch(/z-index:\s*160/i) + expect(popover).toMatch(/max-height:/i) + }) + + it('keeps sql parity filter toolbar actions visually distinct (clear link + primary search)', () => { + const clear = css.match(/\.result-filter-clear\s*\{[\s\S]*?\}/)?.[0] ?? '' + const search = css.match(/\.result-filter-search\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(clear).toMatch(/border:\s*0/i) + expect(search).toMatch(/background:\s*var\(--primary\)/i) + expect(search).toMatch(/color:\s*#fff/i) + expect(css).not.toMatch(/\.result-filter-add-input\s*\{/i) + }) + + it('bounds parity filter popover height and keeps its body scrollable', () => { + const body = css.match(/\.result-filter-panel-body\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(body).toMatch(/min-height:\s*0/i) + expect(body).toMatch(/overflow:\s*auto/i) + }) + + it('styles parity filter export button as a compact toolbar action', () => { + const exportButton = css.match(/\.result-filter-export\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(exportButton).toMatch(/white-space:\s*nowrap/i) + expect(exportButton).toMatch(/height:\s*32px/i) + }) + + it('keeps sql parity filter toolbar above result content for clickability', () => { + const toolbar = css.match(/\.result-filter-toolbar\s*\{[\s\S]*?\}/)?.[0] ?? '' + const anchor = css.match(/\.result-filter-toolbar\s+\.result-filter-anchor\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(toolbar).toMatch(/position:\s*relative/i) + expect(toolbar).toMatch(/z-index:\s*5/i) + expect(toolbar).toMatch(/align-items:\s*flex-start/i) + expect(anchor).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(anchor).toMatch(/min-width:\s*0/i) + }) + + it('styles parity filter popover actions as footer buttons (cancel + primary apply)', () => { + const cancel = css.match(/\.result-filter-cancel\s*\{[\s\S]*?\}/)?.[0] ?? '' + const apply = css.match(/\.result-filter-apply\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(cancel).toMatch(/border:\s*0/i) + // Apply now uses a theme indigo gradient (matches global `.btn`) rather than a flat var(--primary). + expect(apply).toMatch(/background:\s*linear-gradient/i) + expect(apply).toMatch(/var\(--primary\)/i) + expect(apply).toMatch(/color:\s*#fff/i) + }) + + it('keeps parity filter popover theme-aligned and ditches isolated sql-editor tokens', () => { + const popover = css.match(/\.result-filter-popover\s*\{[\s\S]*?\}/)?.[0] ?? '' + const arrow = css.match(/\.result-filter-popover-arrow\s*\{[\s\S]*?\}/)?.[0] ?? '' + const arrowAbove = css.match( + /\.result-filter-popover\[data-placement=['"]above['"]\]\s+\.result-filter-popover-arrow\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + // Single-column two-step layout needs a bit more width than the old dual-column grid. + expect(popover).toMatch(/width:\s*280px/i) + expect(popover).toMatch(/max-height:\s*min\(/i) + expect(popover).toMatch(/top:\s*0/i) + expect(popover).toMatch(/left:\s*0/i) + // Theme tokens replace the isolated sql-editor-* tokens inside the popover. + expect(popover).toMatch(/var\(--surface-strong\)/i) + expect(popover).toMatch(/var\(--edge\)/i) + expect(popover).not.toMatch(/--sql-editor-surface\b/i) + expect(popover).not.toMatch(/--sql-editor-border\b/i) + expect(arrow).toMatch(/transform:\s*rotate\(45deg\)/i) + expect(arrow).toMatch(/left:\s*calc\(var\(--result-filter-arrow-left,\s*16px\)\s*-\s*6px\)/i) + expect(arrowAbove).toMatch(/bottom:\s*-6px/i) + expect(arrowAbove).toMatch(/border-right:/i) + expect(arrowAbove).toMatch(/border-bottom:/i) + }) + + it('keeps the parity filter footer reachable in short windows', () => { + const actions = css.match(/\.result-filter-panel-actions\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(actions).toMatch(/position:\s*sticky/i) + expect(actions).toMatch(/bottom:\s*0/i) + expect(actions).toMatch(/background:/i) + }) + + it('keeps elastic dsl field picker menus opening downward without spacer hacks', () => { + const elasticDslCssPath = path.resolve(__dirname, '..', 'styles', 'console', 'elastic-dsl-parity.css') + const elasticDslCss = fs.readFileSync(elasticDslCssPath, 'utf8') + const basePopover = elasticDslCss.match( + /\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-field-popover\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + const mediumFlip = elasticDslCss.match( + /@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-field-popover\s*\{[\s\S]*?bottom:\s*calc\(100%\s*\+\s*6px\)/i, + )?.[0] ?? '' + const pickerSpacer = elasticDslCss.match( + /\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-field-picker:has\(\.elastic-dsl-field-popover\)\s*\{[\s\S]*?padding-bottom:/i, + )?.[0] ?? '' + + expect(basePopover).toMatch(/position:\s*fixed/i) + expect(mediumFlip).toBe('') + expect(pickerSpacer).toBe('') + }) + + it('keeps parity popover controls sized on the shared theme control-height token', () => { + const title = css.match(/\.result-filter-popover-title\s*\{[\s\S]*?\}/)?.[0] ?? '' + const inputs = css.match(/\.result-filter-popover input,\s*\.result-filter-popover select\s*\{[\s\S]*?\}/)?.[0] ?? '' + const footerButton = css.match(/\.result-filter-panel-actions button\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(title).toMatch(/font-size:\s*10px/i) + // Inputs/selects align with the app-wide --control-height (32px) instead of the old cramped 24px. + expect(inputs).toMatch(/height:\s*var\(--control-height[^)]*\)/i) + expect(inputs).toMatch(/min-height:\s*var\(--control-height[^)]*\)/i) + expect(footerButton).toMatch(/height:\s*var\(--control-height[^)]*\)/i) + }) + + it('styles parity filter operator select like the rest of the toolbar controls', () => { + const select = css.match( + /\.result-filter-popover select\[data-testid=['"]result-filter-operator['"]\]\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + const hover = css.match( + /\.result-filter-popover select\[data-testid=['"]result-filter-operator['"]\]:hover\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + const focusVisible = css.match( + /\.result-filter-popover select\[data-testid=['"]result-filter-operator['"]\]:focus-visible\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(select).toMatch(/appearance:\s*none/i) + expect(select).toMatch(/padding-right:\s*30px/i) + expect(select).toMatch(/background-image:[\s\S]*linear-gradient/i) + expect(select).toMatch(/background-repeat:\s*no-repeat,\s*no-repeat/i) + expect(select).toMatch(/background-position:\s*right\s+10px\s+center,\s*0\s+0/i) + expect(select).toMatch(/background-size:\s*10px\s+6px,\s*100%\s+100%/i) + expect(select).toMatch(/box-shadow:/i) + expect(hover).toMatch(/border-color:/i) + expect(hover).toMatch(/background-image:[\s\S]*linear-gradient/i) + expect(focusVisible).toMatch(/border-color:/i) + expect(focusVisible).toMatch(/box-shadow:[\s\S]*0\s+0\s+0\s+3px/i) + }) + + it('lets parity filter chips wrap while keeping each chip visually bounded', () => { + const toolbarLeft = css.match(/\.result-filter-toolbar-left\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chipList = css.match(/\.result-filter-chip-list\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chipShell = css.match(/\.result-filter-chip-shell\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chipField = css.match(/\.result-filter-chip \.chip-field\s*\{[\s\S]*?\}/)?.[0] ?? '' + const chipValue = css.match(/\.result-filter-chip \.chip-value\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(toolbarLeft).toMatch(/flex-wrap:\s*wrap/i) + expect(chipList).toMatch(/flex-wrap:\s*wrap/i) + expect(chipShell).toMatch(/inline-size:\s*min\(220px,\s*100%\)/i) + expect(chipShell).toMatch(/max-width:\s*100%/i) + expect(chipShell).toMatch(/min-width:\s*0/i) + expect(chipField).toMatch(/overflow:\s*hidden/i) + expect(chipField).toMatch(/text-overflow:\s*ellipsis/i) + expect(chipValue).toMatch(/white-space:\s*nowrap/i) + }) + + it('styles the parity filter hover card as a lightweight copy affordance', () => { + const hoverCard = css.match(/\.result-filter-chip-hover-card\s*\{[\s\S]*?\}/)?.[0] ?? '' + const hoverCopy = css.match(/\.result-filter-chip-hover-copy\s*\{[\s\S]*?\}/)?.[0] ?? '' + const hoverBridge = css.match(/\.result-filter-chip-hover-card::before\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(hoverCard).toMatch(/position:\s*absolute/i) + expect(hoverCard).toMatch(/z-index:\s*7/i) + expect(hoverCard).toMatch(/top:\s*calc\(100%\s*\+\s*4px\)/i) + expect(hoverCard).toMatch(/box-shadow:/i) + expect(hoverBridge).toMatch(/position:\s*absolute/i) + expect(hoverBridge).toMatch(/top:\s*-8px/i) + expect(hoverBridge).toMatch(/left:\s*0/i) + expect(hoverBridge).toMatch(/right:\s*0/i) + expect(hoverBridge).toMatch(/height:\s*8px/i) + expect(hoverCopy).toMatch(/white-space:\s*nowrap/i) + expect(hoverCopy).toMatch(/cursor:\s*pointer/i) + }) + + it('keeps elastic result operations in a single row and allows horizontal overflow when cramped', () => { + const ops = css.match(/\.elastic-results-ops\s*\{[\s\S]*?\}/)?.[0] ?? '' + const actions = css.match(/\.elastic-results-ops-actions\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(ops).toMatch(/overflow-x:\s*auto/i) + expect(actions).toMatch(/display:\s*flex/i) + expect(actions).toMatch(/flex-wrap:\s*nowrap/i) + expect(actions).toMatch(/white-space:\s*nowrap/i) + }) + + it('keeps elastic result rows in a scrollable table without control overlap', () => { + const tableWrap = css.match(/\.elastic-results-table-wrap\s*\{[\s\S]*?\}/)?.[0] ?? '' + const table = css.match(/\.elastic-results-table\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(tableWrap).toMatch(/overflow:\s*auto/i) + expect(table).toMatch(/width:\s*max-content/i) + expect(table).toMatch(/min-width:\s*100%/i) + }) + + it('stacks the elastic footer range and pager on narrow medium widths instead of clipping controls', () => { + const narrowFooter = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.elastic-results-footer\s*\{[\s\S]*?flex-direction:\s*column[\s\S]*?align-items:\s*flex-start/i)?.[0] ?? '' + const narrowPager = css.match(/@media\s*\(max-width:\s*840px\)\s*\{[\s\S]*?\.elastic-results-footer-pager\s*\{[\s\S]*?width:\s*100%[\s\S]*?max-width:\s*100%/i)?.[0] ?? '' + + expect(narrowFooter).not.toBe('') + expect(narrowPager).not.toBe('') + }) + + it('keeps elastic results workspace height-constrained for internal scrolling', () => { + const workspace = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-workspace\s*\{[\s\S]*?\}/)?.[0] ?? '' + const pane = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-pane\s*\{[\s\S]*?\}/)?.[0] ?? '' + const body = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-body\s*\{[\s\S]*?\}/)?.[0] ?? '' + const list = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-list\s*\{[\s\S]*?\}/)?.[0] ?? '' + const tableWrap = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-table-wrap\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(workspace).toMatch(/height:\s*100%/i) + expect(workspace).toMatch(/min-height:\s*0/i) + expect(workspace).toMatch(/display:\s*flex/i) + expect(workspace).toMatch(/flex-direction:\s*column/i) + expect(workspace).toMatch(/box-sizing:\s*border-box/i) + expect(pane).toMatch(/min-height:\s*0/i) + expect(pane).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(body).toMatch(/display:\s*flex/i) + expect(body).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(body).toMatch(/min-height:\s*0/i) + expect(list).toMatch(/height:\s*100%/i) + expect(list).toMatch(/min-height:\s*0/i) + expect(list).toMatch(/display:\s*flex/i) + expect(list).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(tableWrap).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(tableWrap).toMatch(/min-height:\s*0/i) + }) + + it('renders elastic document results as a separate head strip above the scrollable body lane', () => { + const headWrap = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-table-head-wrap\s*\{[\s\S]*?\}/)?.[0] ?? '' + const tableWrap = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-table-wrap\s*\{[\s\S]*?\}/)?.[0] ?? '' + const header = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-table\s+thead\s+th\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(headWrap).toMatch(/overflow:\s*hidden/i) + expect(headWrap).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(headWrap).toMatch(/border-bottom:\s*1px\s+solid/i) + expect(tableWrap).toMatch(/overflow:\s*auto/i) + expect(tableWrap).toMatch(/border-top:\s*0/i) + expect(header).toMatch(/z-index:\s*3/i) + expect(header).toMatch(/background:\s*color-mix\(in\s+oklab,\s*var\(--elastic-surface\)/i) + }) + + it('keeps elastic results actions at 32px tap targets', () => { + const opsButton = css.match(/\.console-results-content--sql-editor\s+\.elastic-ops-button\s*\{[\s\S]*?\}/)?.[0] ?? '' + const viewToggleButton = + css.match(/\.console-results-content--sql-editor\s+\.elastic-results-view-toggle\s+\.elastic-ops-button\s*\{[\s\S]*?\}/)?.[0] ?? '' + const iconButton = css.match(/\.console-results-content--sql-editor\s+\.elastic-ops-button--icon\s*\{[\s\S]*?\}/)?.[0] ?? '' + const rowToggle = css.match(/\.console-results-content--sql-editor\s+\.elastic-row-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const pagerButtons = css.match(/\.console-results-content--sql-editor\s+\.elastic-results-footer-pager\s+button\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(opsButton).toMatch(/min-height:\s*32px/i) + expect(viewToggleButton).toMatch(/min-height:\s*32px/i) + expect(iconButton).toMatch(/height:\s*32px/i) + expect(rowToggle).toMatch(/width:\s*32px/i) + expect(rowToggle).toMatch(/height:\s*32px/i) + expect(pagerButtons).toMatch(/height:\s*32px/i) + expect(pagerButtons).toMatch(/min-width:\s*32px/i) + }) + + it('keeps elastic row-toggle column visually outside the table grid', () => { + const toggleColumn = css.match( + /\.console-results-content--sql-editor\s+\.elastic-results-table\s+th\.elastic-col-toggle,[\s\S]*?\.console-results-content--sql-editor\s+\.elastic-results-table\s+td\.elastic-cell-toggle\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(toggleColumn).toMatch(/width:\s*44px/i) + expect(toggleColumn).toMatch(/text-align:\s*center/i) + expect(toggleColumn).toMatch(/padding:\s*0/i) + }) + + it('defines dark-mode elastic result surface tokens for stitch parity', () => { + const darkWorkspace = css.match(/\.dark\s+\.console-results-content--sql-editor\s+\.elastic-results-workspace\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(darkWorkspace).toMatch(/--elastic-surface:/i) + expect(darkWorkspace).toMatch(/--elastic-border:/i) + }) + + it('styles the elastic cell context menu as a bounded floating action sheet', () => { + const menu = css.match(/\.elastic-cell-context-menu\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(menu).toMatch(/position:\s*fixed/i) + expect(menu).toMatch(/z-index:/i) + expect(menu).toMatch(/border-radius:/i) + }) + + it('defines dedicated semantic pill styles for elastic primitive and structured values', () => { + const numberPill = css.match(/\.elastic-value-pill--number\s*\{[\s\S]*?\}/)?.[0] ?? '' + const booleanPill = css.match(/\.elastic-value-pill--boolean\s*\{[\s\S]*?\}/)?.[0] ?? '' + const arrayPill = css.match(/\.elastic-value-pill--array\s*\{[\s\S]*?\}/)?.[0] ?? '' + const objectPill = css.match(/\.elastic-value-pill--object\s*\{[\s\S]*?\}/)?.[0] ?? '' + const keywordPill = css.match(/\.elastic-value-pill--keyword\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(numberPill).toMatch(/background:/i) + expect(booleanPill).toMatch(/background:/i) + expect(arrayPill).toMatch(/border:/i) + expect(objectPill).toMatch(/border:/i) + expect(keywordPill).toMatch(/background:/i) + }) + + it('defines adaptive width buckets for short and long elastic cell values', () => { + const widthXs = css.match(/\.elastic-result-cell--width-xs\s*\{[\s\S]*?\}/)?.[0] ?? '' + const widthLg = css.match(/\.elastic-result-cell--width-lg\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(widthXs).toMatch(/max-width:/i) + expect(widthXs).toMatch(/min-width:/i) + expect(widthLg).toMatch(/max-width:/i) + expect(widthLg).toMatch(/min-width:/i) + }) + + it('keeps expanded elastic json cards on the same surface family instead of a near-black block', () => { + const jsonCard = css.match(/\.elastic-result-card-body pre\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(jsonCard).toMatch(/background:/i) + expect(jsonCard).toMatch(/var\(--elastic-surface/i) + expect(jsonCard).not.toMatch(/#0b1220/i) + }) + + it('styles elastic dsl field selection as a bounded searchable popover', () => { + const picker = css.match(/\.elastic-dsl-field-picker\s*\{[\s\S]*?\}/)?.[0] ?? '' + const openPicker = css.match(/\.elastic-dsl-field-picker\.is-open\s*\{[\s\S]*?\}/)?.[0] ?? '' + const trigger = css.match(/\.elastic-dsl-field-trigger\s*\{[\s\S]*?\}/)?.[0] ?? '' + const popover = css.match(/\.elastic-dsl-field-popover\s*\{[\s\S]*?\}/)?.[0] ?? '' + const belowSpacing = css.match(/\.elastic-dsl-field-picker:has\(.elastic-dsl-field-popover\[data-placement='below'\]\)\s*\{[\s\S]*?\}/)?.[0] ?? '' + const belowPopover = css.match(/\.elastic-dsl-field-popover\[data-placement='below'\]\s*\{[\s\S]*?\}/)?.[0] ?? '' + const abovePopover = css.match(/\.elastic-dsl-field-popover\[data-placement='above'\]\s*\{[\s\S]*?\}/)?.[0] ?? '' + const search = css.match(/\.elastic-dsl-field-search\s*\{[\s\S]*?\}/)?.[0] ?? '' + const searchInput = css.match(/\.elastic-dsl-field-search\s+input\s*\{[\s\S]*?\}/)?.[0] ?? '' + const option = css.match(/\.elastic-dsl-field-option\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(picker).toMatch(/position:\s*relative/i) + expect(openPicker).toMatch(/z-index:\s*5/i) + expect(trigger).toMatch(/display:\s*flex/i) + expect(trigger).toMatch(/justify-content:\s*space-between/i) + expect(popover).toMatch(/position:\s*fixed/i) + expect(popover).toMatch(/top:\s*0/i) + expect(popover).toMatch(/max-height:/i) + expect(popover).toMatch(/box-shadow:/i) + expect(belowSpacing).toBe('') + expect(belowPopover).toBe('') + expect(abovePopover).toBe('') + expect(search).toMatch(/position:\s*sticky/i) + expect(searchInput).toMatch(/height:\s*32px/i) + expect(searchInput).toMatch(/min-height:\s*32px/i) + expect(option).toMatch(/display:\s*grid/i) + expect(css).not.toMatch(/\.elastic-dsl-field-picker:has\(/i) + }) + + it('keeps elastic-stitch statement builders tall enough to show narrow add-filter actions', () => { + const elasticStitchPanel = css.match( + /\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-statement-panel--elastic-stitch\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(elasticStitchPanel).toMatch(/min-height:\s*max-content/i) + }) + + it('styles elastic dsl operator select like the native console filter control again', () => { + const select = css.match(/\.elastic-dsl-filter-editor\s+\.elastic-dsl-filter-operator-select\s*\{[\s\S]*?\}/)?.[0] ?? '' + const hover = css.match( + /\.elastic-dsl-filter-editor\s+\.elastic-dsl-filter-operator-select:hover\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + const focusVisible = css.match( + /\.elastic-dsl-filter-editor\s+\.elastic-dsl-filter-operator-select:focus-visible,\s*[\s\S]*?\.elastic-dsl-field-trigger:focus-visible\s*\{[\s\S]*?\}/, + )?.[0] ?? '' + + expect(select).toMatch(/appearance:\s*none/i) + expect(select).toMatch(/padding-right:\s*30px/i) + expect(select).toMatch(/border-color:/i) + expect(select).toMatch(/background-image:/i) + // Direction A (TASK-20260513-195708): dropped the gradient background + // layer; the chevron is now the only background-image. background-color + // owns the surface fill. + expect(select).toMatch(/background-repeat:\s*no-repeat/i) + expect(select).toMatch(/background-position:\s*right\s+10px\s+center/i) + expect(select).toMatch(/background-color:\s*var\(--sql-editor-surface\)/i) + expect(hover).toMatch(/background-color:/i) + expect(focusVisible).toMatch(/box-shadow:[\s\S]*0\s+0\s+0\s+3px/i) + expect(css).not.toMatch(/\.elastic-dsl-operator-popover\s*\{/i) + expect(css).not.toMatch(/\.elastic-dsl-filter-operator-trigger\s*\{/i) + }) + + it('keeps elastic dsl value composer wrapping tokens while leaving room for typing', () => { + const composer = css.match(/\.elastic-dsl-filter-value-composer\s*\{[\s\S]*?\}/)?.[0] ?? '' + const actions = css.match(/\.elastic-dsl-filter-actions\s*\{[\s\S]*?\}/)?.[0] ?? '' + const token = css.match(/\.elastic-dsl-value-token\s*\{[\s\S]*?\}/)?.[0] ?? '' + const input = css.match(/\.elastic-dsl-filter-value-input\s*\{[\s\S]*?\}/)?.[0] ?? '' + const tokenRemove = css.match(/\.elastic-dsl-filter-value-composer\s+\.elastic-dsl-value-token-remove\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(composer).toMatch(/display:\s*flex/i) + expect(composer).toMatch(/flex-wrap:\s*wrap/i) + expect(composer).toMatch(/align-items:\s*center/i) + expect(actions).toMatch(/margin-left:\s*auto/i) + expect(actions).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(actions).toMatch(/white-space:\s*nowrap/i) + expect(token).toMatch(/display:\s*inline-flex/i) + expect(token).toMatch(/border-radius:/i) + expect(input).toMatch(/flex:\s*1\s+1\s+120px/i) + expect(input).toMatch(/min-width:\s*120px/i) + expect(tokenRemove).toMatch(/height:\s*auto/i) + expect(tokenRemove).toMatch(/min-height:\s*0/i) + expect(tokenRemove).toMatch(/border:\s*0/i) + expect(tokenRemove).toMatch(/background:\s*transparent/i) + expect(tokenRemove).toMatch(/padding:\s*0/i) + expect(tokenRemove).toMatch(/box-shadow:\s*none/i) + expect(css).toMatch(/input\[data-testid=['"]elastic-dsl-filter-value['"]\]:not\(\.elastic-dsl-filter-value-input\)/i) + }) + + it('stacks the elastic dsl filter editor into a single-column lane on narrow desktop widths', () => { + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)/i) + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.elastic-dsl-filter-editor\s*\{[\s\S]*?display:\s*grid/i) + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.elastic-dsl-filter-actions\s*\{[\s\S]*?justify-content:\s*flex-end/i) + }) + + it('gives the elastic dsl editor a wider code-surface rhythm instead of a narrow strip', () => { + const drawer = css.match(/\.elastic-dsl-drawer\s*\{[\s\S]*?\}/)?.[0] ?? '' + const editorShell = css.match(/\.elastic-dsl-editor-shell\s*\{[\s\S]*?\}/)?.[0] ?? '' + const editorPane = css.match(/\.elastic-dsl-editor-pane\s*\{[\s\S]*?\}/)?.[0] ?? '' + const highlight = css.match(/\.elastic-dsl-editor-highlight\s*\{[\s\S]*?\}/)?.[0] ?? '' + const lineNumbersInner = css.match(/\.elastic-dsl-line-numbers-inner\s*\{[\s\S]*?\}/)?.[0] ?? '' + const editor = css.match(/\.elastic-dsl-editor\s*\{[\s\S]*?\}/)?.[0] ?? '' + const scrollbarMask = css.match(/\.elastic-dsl-editor-scrollbar-mask\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(drawer).toMatch(/padding:\s*18px\s+20px\s+22px/i) + expect(editorShell).toMatch(/height:\s*var\(--elastic-dsl-editor-height,\s*320px\)/i) + expect(editorShell).toMatch(/min-height:\s*var\(--elastic-dsl-editor-height,\s*320px\)/i) + expect(editorShell).toMatch(/border-radius:\s*18px/i) + expect(editorPane).toMatch(/position:\s*relative/i) + expect(editorPane).toMatch(/overflow:\s*hidden/i) + expect(css).toMatch(/\.elastic-dsl-editor-pane::before\s*\{/i) + expect(css).toMatch(/\.elastic-dsl-editor-pane::after\s*\{/i) + expect(highlight).toMatch(/position:\s*absolute/i) + expect(highlight).toMatch(/pointer-events:\s*none/i) + expect(highlight).toMatch(/white-space:\s*pre-wrap/i) + expect(highlight).toMatch(/padding:\s*14px\s+20px\s+16px/i) + expect(highlight).toMatch(/line-height:\s*18px/i) + expect(highlight).toMatch(/min-height:\s*100%/i) + expect(css).toMatch(/@media\s*\(max-width:\s*980px\)[\s\S]*?\.elastic-dsl-editor-highlight,\s*[\s\S]*?height:\s*var\(--elastic-dsl-editor-height,\s*320px\)/i) + expect(lineNumbersInner).toMatch(/position:\s*absolute/i) + expect(lineNumbersInner).toMatch(/padding:\s*14px\s+8px\s+16px/i) + expect(editor).toMatch(/background:\s*transparent/i) + expect(editor).toMatch(/color:\s*transparent/i) + expect(editor).toMatch(/caret-color:/i) + expect(editor).toMatch(/white-space:\s*pre-wrap/i) + expect(editor).toMatch(/overflow-wrap:\s*anywhere/i) + expect(editor).toMatch(/padding:\s*14px\s+20px\s+16px/i) + expect(editor).toMatch(/line-height:\s*18px/i) + expect(editor).toMatch(/scrollbar-width:\s*none/i) + expect(editor).toMatch(/height:\s*var\(--elastic-dsl-editor-height,\s*320px\)/i) + expect(editor).toMatch(/min-height:\s*var\(--elastic-dsl-editor-height,\s*320px\)/i) + expect(css).not.toMatch(/min\(var\(--elastic-dsl-editor-height,\s*320px\),\s*500px\)/i) + expect(css).toMatch(/\.elastic-dsl-editor::\-webkit-scrollbar\s*\{/i) + expect(css).toMatch(/\.elastic-dsl-editor::\-webkit-scrollbar\s*\{[\s\S]*?width:\s*0/i) + expect(css).toMatch(/\.elastic-dsl-editor::\-webkit-scrollbar\s*\{[\s\S]*?height:\s*0/i) + expect(scrollbarMask).toMatch(/position:\s*absolute/i) + expect(scrollbarMask).toMatch(/right:\s*0/i) + expect(scrollbarMask).toMatch(/width:\s*18px/i) + expect(scrollbarMask).toMatch(/pointer-events:\s*none/i) + }) + + it('keeps elastic-stitch code layers on one shared font token so caret metrics stay aligned', () => { + const codeSurface = css.match(/\.elastic-dsl-editor-shell\s*\{[\s\S]*?--elastic-dsl-code-font-family:[\s\S]*?\}/)?.[0] ?? '' + const lineNumbers = css.match(/\.elastic-dsl-line-numbers\s*\{[\s\S]*?font-family:\s*var\(--elastic-dsl-code-font-family\)/i)?.[0] ?? '' + const highlight = css.match(/\.elastic-dsl-editor-highlight\s*\{[\s\S]*?font-family:\s*var\(--elastic-dsl-code-font-family\)/i)?.[0] ?? '' + const editor = css.match(/\.elastic-dsl-editor\s*\{[\s\S]*?font-family:\s*var\(--elastic-dsl-code-font-family\)/i)?.[0] ?? '' + const tokenSpan = css.match(/\.elastic-dsl-json-token\s*\{[\s\S]*?font-family:\s*var\(--elastic-dsl-code-font-family\)/i)?.[0] ?? '' + const elasticStitchOverride = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch[\s\S]*?\.elastic-dsl-editor-shell\s*\{[\s\S]*?--elastic-dsl-code-font-family:[\s\S]*?\}/i)?.[0] ?? '' + + expect(codeSurface).toMatch(/--elastic-dsl-code-font-family:/i) + expect(lineNumbers).toMatch(/font-family:\s*var\(--elastic-dsl-code-font-family\)/i) + expect(highlight).toMatch(/font-family:\s*var\(--elastic-dsl-code-font-family\)/i) + expect(editor).toMatch(/font-family:\s*var\(--elastic-dsl-code-font-family\)/i) + expect(tokenSpan).toMatch(/font-family:\s*var\(--elastic-dsl-code-font-family\)/i) + expect(elasticStitchOverride).toMatch(/--elastic-dsl-code-font-family:/i) + }) + + it('keeps unsupported builder notice aligned to the 12px medium-width gutters', () => { + const mediumNotice = css.match( + /@media\s*\(max-width:\s*980px\)\s*\{[\s\S]*?\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-unsupported-notice\s*\{[\s\S]*?margin:\s*0\s+12px\s+14px/i, + )?.[0] ?? '' + + expect(mediumNotice).toMatch(/margin:\s*0\s+12px\s+14px/i) + }) + + it('keeps elastic dsl toolbar controls as non-shrinking 32px targets before compact stacking', () => { + const addFilter = css.match(/\.elastic-add-filter-btn\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const liveToggle = css.match(/\.elastic-live-toggle\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const reset = css.match(/\.elastic-reset-btn\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const run = css.match(/\.elastic-run-btn\s*\{[\s\S]*?\}/i)?.[0] ?? '' + + expect(css).toMatch(/\.elastic-dsl-toolbar-right\s*\{[\s\S]*?overflow-x:\s*auto/i) + expect(css).toMatch(/\.elastic-dsl-toolbar-right\s*\{[\s\S]*?white-space:\s*nowrap/i) + expect(addFilter).toMatch(/min-height:\s*32px/i) + expect(addFilter).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(liveToggle).toMatch(/min-height:\s*32px/i) + expect(liveToggle).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(reset).toMatch(/min-height:\s*32px/i) + expect(reset).toMatch(/display:\s*inline-flex/i) + expect(reset).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(run).toMatch(/height:\s*32px/i) + expect(run).toMatch(/flex:\s*0\s+0\s+auto/i) + }) + + it('keeps virtualized sql and elastic result headers sticky by preserving separate table borders', () => { + const resultTable = css.match(/\.console-results-content--sql-editor\s+\.result-table\s*\{[\s\S]*?\}/)?.[0] ?? '' + const resultHeader = css.match(/\.console-results-content--sql-editor\s+\.result-table\s+thead\s+th\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(resultTable).toMatch(/border-collapse:\s*separate\s*!important/i) + expect(resultTable).toMatch(/border-spacing:\s*0/i) + expect(resultHeader).toMatch(/position:\s*sticky/i) + expect(resultHeader).toMatch(/top:\s*0/i) + }) + + it('wraps elastic dsl drawer actions onto a second line at compact widths', () => { + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)/i) + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)[\s\S]*?\.elastic-dsl-drawer-head\s*\{[\s\S]*?flex-wrap:\s*wrap/i) + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)[\s\S]*?\.elastic-dsl-drawer-actions\s*\{[\s\S]*?width:\s*100%/i) + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)[\s\S]*?\.elastic-dsl-drawer-actions\s*\{[\s\S]*?justify-content:\s*flex-start/i) + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)[\s\S]*?\.elastic-dsl-drawer-actions\s*\{[\s\S]*?flex-wrap:\s*wrap/i) + expect(css).toMatch(/@media\s*\(max-width:\s*760px\)[\s\S]*?\.elastic-dsl-drawer-actions\s*\{[\s\S]*?overflow-x:\s*visible/i) + }) + + it('lets elastic dsl drawer chrome reflow before compact breakpoints so docked ai sidebars do not clip action labels', () => { + const drawerHead = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-drawer-head\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const drawerStatus = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-drawer-status\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const drawerActions = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-drawer-actions\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const drawerButtons = css.match(/\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-drawer-actions button\s*\{[\s\S]*?\}/i)?.[0] ?? '' + + expect(drawerHead).toMatch(/flex-wrap:\s*wrap/i) + expect(drawerStatus).toMatch(/flex:\s*1\s+1\s+240px/i) + expect(drawerStatus).toMatch(/min-width:\s*0/i) + expect(drawerActions).toMatch(/justify-content:\s*flex-end/i) + expect(drawerActions).toMatch(/max-width:\s*100%/i) + expect(drawerActions).toMatch(/overflow-x:\s*auto/i) + expect(drawerActions).toMatch(/flex:\s*0\s+1\s+auto/i) + expect(drawerButtons).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(drawerButtons).toMatch(/white-space:\s*nowrap/i) + }) + + it('keeps elastic document results from collapsing to a sliver on compact widths', () => { + const baseRule = css.match(/\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--statement\.sql-editor-parity\s+\.console-editor-results-shell\.sql-editor-parity\s*\{[\s\S]*?grid-template-rows:\s*auto\s+minmax\(0,\s*1fr\)[\s\S]*?\}/i)?.[0] ?? '' + const narrowDesktopRule = css.match(/@media\s*\(max-width:\s*840px\)[\s\S]*?grid-template-rows:\s*auto\s+minmax\(220px,\s*1fr\)/i)?.[0] ?? '' + const compactRule = css.match(/@media\s*\(max-width:\s*760px\)[\s\S]*?grid-template-rows:\s*auto\s+minmax\(240px,\s*1fr\)/i)?.[0] ?? '' + const baseRuleIndex = css.indexOf(baseRule) + const narrowDesktopIndex = css.lastIndexOf('grid-template-rows: auto minmax(220px, 1fr);') + const compactIndex = css.lastIndexOf('grid-template-rows: auto minmax(240px, 1fr);') + + expect(baseRule).toMatch(/grid-template-rows:\s*auto\s+minmax\(0,\s*1fr\)/i) + expect(narrowDesktopRule).toMatch(/grid-template-rows:\s*auto\s+minmax\(220px,\s*1fr\)/i) + expect(compactRule).toMatch(/grid-template-rows:\s*auto\s+minmax\(240px,\s*1fr\)/i) + expect(narrowDesktopIndex).toBeGreaterThan(baseRuleIndex) + expect(compactIndex).toBeGreaterThan(narrowDesktopIndex) + }) + + it('keeps narrow elastic live dsl content inside its own scroll lane instead of spilling into results', () => { + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-statement-panel--elastic-stitch\s*\{[\s\S]*?min-height:\s*0/i) + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-workspace\s*\{[\s\S]*?min-height:\s*0/i) + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-workspace\s*\{[\s\S]*?overflow:\s*auto/i) + expect(css).toMatch(/@media\s*\(max-width:\s*840px\)[\s\S]*?\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-panel--statement\.sql-editor-parity\s+\.elastic-dsl-workspace\s*\{[\s\S]*?overscroll-behavior:\s*contain/i) + }) + + it('keeps elastic query tabs on the same sql tab chrome instead of hiding the session row', () => { + const elasticPanel = css.match( + /\.console-shell\.sql-editor-parity\.elastic-stitch\s+\.console-statement-panel--elastic-stitch\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const sharedSqlTabs = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tabs,\s*\.redis-session-tabs-shell\s+\.statement-tabs\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + + expect(elasticPanel).toMatch(/grid-template-rows:\s*auto\s+minmax\(0,\s*1fr\)/i) + expect(sharedSqlTabs).toMatch(/height:\s*42px/i) + expect(sharedSqlTabs).toMatch(/border-bottom:\s*1px\s+solid\s+var\(--sql-editor-border\)/i) + }) + + it('keeps sql-editor and redis query tabs horizontally scrollable with pinned controls and overflow affordance', () => { + const redisShellTokens = css.match(/\.redis-proto-shell\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const redisShellDarkTokens = css.match(/\.dark\s+\.redis-proto-shell\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const sharedTabChrome = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tabs,\s*\.redis-session-tabs-shell\s+\.statement-tabs\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const sharedSqlTabs = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tabs-list,\s*\.redis-session-tabs-shell\s+\.statement-tabs-list\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const viewportRule = css.match(/\.statement-tabs-viewport\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const viewportOverflowLeftRule = css.match( + /\.statement-tabs-viewport\.statement-tabs-viewport--overflow-left\s+\.statement-tabs-list\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const viewportOverflowRightRule = css.match( + /\.statement-tabs-viewport\.statement-tabs-viewport--overflow-right\s+\.statement-tabs-list\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const scrollButtonRule = css.match(/\.statement-tabs-scroll\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const scrollDisabledRule = css.match(/\.statement-tabs-scroll:disabled\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const scrollRightRule = css.match(/\.statement-tabs-scroll\.statement-tabs-scroll--right\s*\{[\s\S]*?\}/i)?.[0] ?? '' + const iconRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab--sql-editor\s+\.statement-tab-datasource-icon,\s*\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\s+\.statement-tab-datasource-icon\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const addButtonRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab-add--sql-editor,\s*\.redis-session-tabs-shell\s+\.statement-tab-add--sql-editor\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const tabButtonRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab--sql-editor,\s*\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const draggingRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab--sql-editor\.statement-tab--dragging,\s*\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\.statement-tab--dragging\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const dropBeforeRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab--sql-editor\.statement-tab--drop-before,\s*\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\.statement-tab--drop-before\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + const dropAfterRule = css.match( + /\.console-statement-panel--sql-editor\s+\.statement-tab--sql-editor\.statement-tab--drop-after,\s*\.redis-session-tabs-shell\s+\.statement-tab--sql-editor\.statement-tab--drop-after\s*\{[\s\S]*?\}/i, + )?.[0] ?? '' + + expect(redisShellTokens).toMatch(/--statement-tab-rail:/i) + expect(redisShellTokens).toMatch(/--statement-tab-fill-active:/i) + expect(redisShellDarkTokens).toMatch(/--statement-tab-rail:/i) + expect(sharedTabChrome).toMatch(/padding:\s*0/i) + expect(sharedTabChrome).toMatch(/position:\s*relative/i) + expect(sharedTabChrome).toMatch(/z-index:\s*3/i) + expect(sharedTabChrome).toMatch(/min-height:\s*42px/i) + expect(sharedTabChrome).toMatch(/flex:\s*0\s+0\s+42px/i) + expect(sharedTabChrome).toMatch(/width:\s*100%/i) + expect(sharedTabChrome).toMatch(/max-width:\s*100%/i) + expect(sharedTabChrome).toMatch(/overflow:\s*hidden/i) + expect(sharedTabChrome).toMatch(/gap:\s*0/i) + expect(sharedSqlTabs).toMatch(/height:\s*100%/i) + expect(sharedSqlTabs).toMatch(/flex:\s*1\s+1\s+auto/i) + expect(sharedSqlTabs).toMatch(/min-width:\s*0/i) + expect(sharedSqlTabs).toMatch(/overflow-x:\s*auto/i) + expect(sharedSqlTabs).toMatch(/padding:\s*0/i) + expect(viewportRule).toMatch(/display:\s*flex/i) + expect(viewportRule).toMatch(/align-items:\s*stretch/i) + expect(viewportRule).toMatch(/height:\s*100%/i) + expect(viewportRule).toMatch(/position:\s*relative/i) + expect(viewportRule).toMatch(/overflow:\s*hidden/i) + expect(viewportOverflowLeftRule).toMatch(/padding-left:\s*24px/i) + expect(viewportOverflowRightRule).toMatch(/padding-right:\s*24px/i) + expect(scrollButtonRule).toMatch(/flex:\s*0\s+0\s+24px/i) + expect(scrollButtonRule).toMatch(/width:\s*24px/i) + // Direction A migration (TASK-20260513-195708): the statement-tabs rail no + // longer wraps the scroll arrows in a filled chip — the underline-indicator + // tabs sit on a transparent background, and the scroll buttons inherit. + expect(scrollButtonRule).toMatch(/background:\s*transparent/i) + expect(scrollButtonRule).toMatch(/box-sizing:\s*border-box/i) + expect(scrollDisabledRule).toMatch(/opacity:\s*1/i) + expect(scrollRightRule).toMatch(/position:\s*absolute/i) + expect(scrollRightRule).toMatch(/right:\s*48px/i) + // Direction A migration (TASK-20260513-195708): the parity shell migrated + // from IBM Plex Mono → JetBrains Mono to match the Redis shell typography. + expect(tabButtonRule).toMatch(/font-family:\s*'JetBrains Mono'/i) + expect(tabButtonRule).toMatch(/font-size:\s*12px/i) + expect(tabButtonRule).toMatch(/box-sizing:\s*border-box/i) + expect(tabButtonRule).toMatch(/align-self:\s*stretch/i) + expect(tabButtonRule).toMatch(/cursor:\s*grab/i) + expect(draggingRule).toMatch(/cursor:\s*grabbing/i) + expect(draggingRule).toMatch(/opacity:\s*0\.74/i) + expect(dropBeforeRule).toMatch(/box-shadow:\s*inset\s+3px\s+0\s+0\s+var\(--primary\)/i) + expect(dropAfterRule).toMatch(/box-shadow:\s*inset\s+-3px\s+0\s+0\s+var\(--primary\)/i) + expect(iconRule).toMatch(/width:\s*16px/i) + expect(iconRule).toMatch(/height:\s*16px/i) + expect(iconRule).toMatch(/flex:\s*0\s+0\s+16px/i) + expect(iconRule).toMatch(/display:\s*block/i) + expect(addButtonRule).toMatch(/flex:\s*0\s+0\s+48px/i) + expect(addButtonRule).toMatch(/font-family:\s*'JetBrains Mono'/i) + expect(addButtonRule).toMatch(/margin:\s*0/i) + expect(addButtonRule).toMatch(/box-sizing:\s*border-box/i) + }) + + it('keeps elastic index fields filter rows baseline-safe with compact checkboxes', () => { + const fieldRow = css.match(/\.es-index-field-item\s*\{[\s\S]*?\}/)?.[0] ?? '' + const fieldCheckbox = css.match(/\.es-index-field-item\s+input\[type="checkbox"\]\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(fieldRow).toMatch(/display:\s*flex/i) + expect(fieldRow).toMatch(/align-items:\s*center/i) + expect(fieldCheckbox).toMatch(/width:\s*16px/i) + expect(fieldCheckbox).toMatch(/height:\s*16px/i) + expect(fieldCheckbox).toMatch(/min-height:\s*16px/i) + expect(fieldCheckbox).toMatch(/flex:\s*0\s+0\s+16px/i) + }) +}) diff --git a/frontend/src/__tests__/main-layout-auth-gate.test.ts b/frontend/src/__tests__/main-layout-auth-gate.test.ts new file mode 100644 index 0000000..db4579b --- /dev/null +++ b/frontend/src/__tests__/main-layout-auth-gate.test.ts @@ -0,0 +1,209 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +const runtimeEventHandlers = new Map void>() + +vi.mock('@wailsjs/runtime/runtime', () => ({ + EventsOn: vi.fn((event: string, handler: (payload: any) => void) => { + runtimeEventHandlers.set(event, handler) + return () => runtimeEventHandlers.delete(event) + }), +})) + +import MainLayout from '@/core/layout/MainLayout.vue' +import { api } from '@/services/api' + +const Dummy = { template: '
dummy
' } + +const buildRouter = () => + createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', name: 'datasources', component: Dummy }], + }) + +describe('MainLayout auth gate', () => { + beforeEach(() => { + setActivePinia(createPinia()) + runtimeEventHandlers.clear() + ;(window as any).runtime = {} + }) + + afterEach(() => { + vi.restoreAllMocks() + runtimeEventHandlers.clear() + ;(window as any).runtime = undefined + }) + + it('loads the app shell with local data when no session exists', async () => { + vi.spyOn(api, 'ensureAuthenticated').mockResolvedValue({ deviceId: 'device_local', session: null } as any) + const listDatasources = vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + const listAIConfigs = vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + + const router = buildRouter() + await router.push('/') + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="auth-gate"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + expect(listDatasources).toHaveBeenCalledTimes(1) + expect(listAIConfigs).toHaveBeenCalledTimes(1) + }) + + it('loads the app shell after restoring a signed-in session', async () => { + vi.spyOn(api, 'ensureAuthenticated').mockResolvedValue({ + deviceId: 'device_local', + session: { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Auth User', + avatarUrl: '', + }, + license: { + plan: 'pro', + status: 'active', + expiresAt: 0, + }, + }, + } as any) + const listDatasources = vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + const listAIConfigs = vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + + const router = buildRouter() + await router.push('/') + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="auth-gate"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + expect(listDatasources).toHaveBeenCalledTimes(1) + expect(listAIConfigs).toHaveBeenCalledTimes(1) + }) + + it('switches from the login gate to the app shell after auth callback events', async () => { + vi.spyOn(api, 'ensureAuthenticated').mockResolvedValue({ deviceId: 'device_local', session: null } as any) + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ devices: [], limit: 1, plan: 'free' } as any) + const listDatasources = vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + const listAIConfigs = vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + + const router = buildRouter() + await router.push('/') + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + expect(wrapper.find('[data-testid="auth-gate"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + + const authStateHandler = runtimeEventHandlers.get('auth:state') + expect(authStateHandler).toBeTypeOf('function') + authStateHandler?.({ + deviceId: 'device_local', + session: { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Auth User', + avatarUrl: '', + }, + license: { + plan: 'pro', + status: 'active', + expiresAt: 0, + }, + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="auth-gate"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + expect(listDatasources).toHaveBeenCalledTimes(1) + expect(listAIConfigs).toHaveBeenCalledTimes(1) + }) + + it('requires in-app confirmation before authorizing the Codex plugin deep link', async () => { + vi.spyOn(api, 'ensureAuthenticated').mockResolvedValue({ deviceId: 'device_local', session: null } as any) + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + const authorize = vi.spyOn(api, 'authorizeCodexPlugin').mockResolvedValue({ + installed: [{ id: 'codex', name: 'Codex', path: '~/.futrixdata/codex-plugin.json', success: true }], + } as any) + + const router = buildRouter() + await router.push('/') + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + const connectHandler = runtimeEventHandlers.get('codex:connect-request') + expect(connectHandler).toBeTypeOf('function') + connectHandler?.({ source: 'codex-plugin' }) + await wrapper.vm.$nextTick() + + expect(wrapper.find('[data-testid="codex-connect-dialog"]').exists()).toBe(true) + expect(authorize).not.toHaveBeenCalled() + + await wrapper.get('[data-testid="codex-connect-confirm"]').trigger('click') + await flushPromises() + + expect(authorize).toHaveBeenCalledTimes(1) + expect(wrapper.find('[data-testid="codex-connect-dialog"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/main-layout-sql-editor-immersive.test.ts b/frontend/src/__tests__/main-layout-sql-editor-immersive.test.ts new file mode 100644 index 0000000..cd9c201 --- /dev/null +++ b/frontend/src/__tests__/main-layout-sql-editor-immersive.test.ts @@ -0,0 +1,106 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import MainLayout from '@/core/layout/MainLayout.vue' +import { api } from '@/services/api' + +const Dummy = { template: '
dummy
' } + +const buildRouter = () => + createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy, meta: { title: 'Data Sources' } }, + { path: '/console/:id', name: 'console', component: Dummy, meta: { title: 'Console' } }, + ], + }) + +describe('MainLayout shell chrome visibility', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.spyOn(api, 'ensureAuthenticated').mockResolvedValue({ + deviceId: 'device_local', + session: { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Auth User', + avatarUrl: '', + }, + license: { + plan: 'pro', + status: 'active', + expiresAt: 0, + }, + }, + } as any) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps shell chrome for sql-editor parity datasource on console route', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { id: 'ds_mysql', name: 'MySQL', type: 'mysql', host: '', port: 3306 } as any, + ]) + + const router = buildRouter() + await router.push({ name: 'console', params: { id: 'ds_mysql' } }) + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.classes()).not.toContain('sql-editor-immersive') + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="title-bar-stub"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="app-ai-stub"]').exists()).toBe(true) + }) + + it('keeps shell chrome for non-parity datasource on console route', async () => { + vi.spyOn(api, 'listDatasources').mockResolvedValue([ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '', port: 6379 } as any, + ]) + + const router = buildRouter() + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + const wrapper = mount(MainLayout, { + global: { + plugins: [router], + stubs: { + Sidebar: { template: '' }, + TitleBar: { template: '
title
' }, + AiSidebar: { template: '' }, + NoticeBanner: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.classes()).not.toContain('sql-editor-immersive') + expect(wrapper.find('[data-testid="app-nav-stub"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="title-bar-stub"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="app-ai-stub"]').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/manual-install-dialog.test.ts b/frontend/src/__tests__/manual-install-dialog.test.ts new file mode 100644 index 0000000..715c67e --- /dev/null +++ b/frontend/src/__tests__/manual-install-dialog.test.ts @@ -0,0 +1,90 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import ManualInstallDialog from '@/components/skill/ManualInstallDialog.vue' +import { api } from '@/services/api' + +describe('ManualInstallDialog', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('loads a bound agent identity and saves the renamed agent before copy', async () => { + vi.spyOn(api, 'getManualInstallInfo').mockResolvedValue({ + cliBinaryPath: '/usr/local/bin/futrixdata-cli', + accessKey: 'agent_1234', + agentName: 'agent-1234', + skillTemplates: [ + { + id: 'claude', + name: 'Claude Code', + filename: 'SKILL.md', + suggestedPath: '~/.claude/skills/futrixdata/SKILL.md', + content: 'futrixdata-cli --agent-access-key agent_1234 tool list --schema --json', + }, + ], + mcpSnippets: [], + } as any) + const renameSpy = vi.spyOn(api, 'renameAgentIdentity').mockResolvedValue({ accessKey: 'agent_1234', name: 'warehouse-bot' } as any) + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + + const wrapper = mount(ManualInstallDialog, { + global: { + plugins: [createPinia()], + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="manual-install-approval-policy"]').text()).toContain('Third-party agents cannot approve') + + const input = wrapper.find('[data-testid="manual-agent-name-input"]') + await input.setValue('warehouse-bot') + await wrapper.find('[data-testid="manual-copy-skill-claude"]').trigger('click') + await flushPromises() + + expect(renameSpy).toHaveBeenCalledWith('agent_1234', 'warehouse-bot') + expect(writeText).toHaveBeenCalled() + expect(wrapper.text()).toContain('Agent name') + }) + + it('still copies snippets when agent rename persistence fails', async () => { + vi.spyOn(api, 'getManualInstallInfo').mockResolvedValue({ + cliBinaryPath: '/usr/local/bin/futrixdata-cli', + accessKey: 'agent_1234', + agentName: 'agent-1234', + skillTemplates: [ + { + id: 'claude', + name: 'Claude Code', + filename: 'SKILL.md', + suggestedPath: '~/.claude/skills/futrixdata/SKILL.md', + content: 'futrixdata-cli --agent-access-key agent_1234 tool list --schema --json', + }, + ], + mcpSnippets: [], + } as any) + vi.spyOn(api, 'renameAgentIdentity').mockRejectedValue(new Error('save failed')) + const writeText = vi.fn().mockResolvedValue(undefined) + Object.assign(navigator, { clipboard: { writeText } }) + + const wrapper = mount(ManualInstallDialog, { + global: { + plugins: [createPinia()], + }, + }) + await flushPromises() + + await wrapper.find('[data-testid="manual-agent-name-input"]').setValue('warehouse-bot') + await wrapper.find('[data-testid="manual-copy-skill-claude"]').trigger('click') + await flushPromises() + + expect(writeText).toHaveBeenCalledWith('futrixdata-cli --agent-access-key agent_1234 tool list --schema --json') + }) +}) diff --git a/frontend/src/__tests__/my-account-plan-limit.test.ts b/frontend/src/__tests__/my-account-plan-limit.test.ts new file mode 100644 index 0000000..bb63c9b --- /dev/null +++ b/frontend/src/__tests__/my-account-plan-limit.test.ts @@ -0,0 +1,157 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})) + +describe('My account plan and device limit', () => { + let pinia: ReturnType + + const mountAccountPanel = () => { + const authStore = useAuthStore() + authStore.state.deviceId = 'device_local' + authStore.state.session = { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Plan User', + avatarUrl: '', + }, + license: { + plan: 'free', + status: 'active', + expiresAt: 0, + }, + } as any + return mount(MyView, { + global: { + plugins: [pinia], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + }, + }, + }) + } + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [ + { + deviceId: 'device_local', + deviceName: 'Current Device', + platform: 'macos', + lastActiveAt: Date.now(), + createdAt: Date.now(), + }, + ], + limit: 1, + plan: 'free', + } as any) + }) + + it('shows the current plan and device limit in the account panel', async () => { + const wrapper = mountAccountPanel() + await flushPromises() + + expect(wrapper.text()).toContain('Free') + expect(wrapper.text()).toContain('1') + }) + + it('shows Free local-use status and a sign-in action when no session exists', async () => { + const authStore = useAuthStore() + authStore.state.deviceId = 'device_local' + authStore.state.session = null as any + vi.spyOn(authStore, 'startLogin').mockResolvedValue({ loginUrl: 'https://auth.example.test', sessionId: 'session_1' } as any) + + const wrapper = mount(MyView, { + global: { + plugins: [pinia], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + }, + }, + }) + await flushPromises() + + expect(wrapper.get('[data-testid="my-account-plan-row"]').text()).toContain('Free') + expect(wrapper.get('[data-testid="my-account-login"]').text()).toContain(tApp('auth.login.start')) + expect(wrapper.find('[data-testid="my-account-logout"]').exists()).toBe(false) + }) + + it('renders pretty-printed platform names and marks the current device', async () => { + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [ + { + deviceId: 'device_remote_win', + deviceName: 'Workstation', + platform: 'windows', + lastActiveAt: Date.now() - 60_000, + createdAt: Date.now() - 86_400_000, + }, + { + deviceId: 'device_local', + deviceName: 'Laptop', + platform: 'macos', + lastActiveAt: Date.now(), + createdAt: Date.now(), + }, + ], + limit: 5, + plan: 'pro', + } as any) + + const wrapper = mountAccountPanel() + await flushPromises() + + const cards = wrapper.findAll('.my-device-card') + expect(cards).toHaveLength(2) + // Current device sorted first. + expect(cards[0].attributes('data-testid')).toBe('my-device-card-current') + expect(cards[0].text()).toContain('Laptop') + expect(cards[0].text()).toContain('macOS') + expect(cards[0].text()).toContain('This device') + // Other device pretty-prints platform too. + expect(cards[1].text()).toContain('Workstation') + expect(cards[1].text()).toContain('Windows') + expect(cards[1].text()).not.toContain('This device') + }) + + it('falls back to a friendly title when deviceName is empty', async () => { + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [ + { + deviceId: 'device_local', + deviceName: '', + platform: 'linux', + lastActiveAt: Date.now(), + createdAt: Date.now(), + }, + ], + limit: 1, + plan: 'free', + } as any) + + const wrapper = mountAccountPanel() + await flushPromises() + + const card = wrapper.find('.my-device-card') + expect(card.exists()).toBe(true) + expect(card.text()).toContain('Unnamed Linux device') + expect(card.text()).toContain('Linux') + }) +}) diff --git a/frontend/src/__tests__/my-knowledge-base-i18n.test.ts b/frontend/src/__tests__/my-knowledge-base-i18n.test.ts new file mode 100644 index 0000000..20161c8 --- /dev/null +++ b/frontend/src/__tests__/my-knowledge-base-i18n.test.ts @@ -0,0 +1,59 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import MyKnowledgeBaseView from '@/views/MyKnowledgeBaseView.vue' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: vi.fn() }), +})) + +describe('MyKnowledgeBaseView i18n', () => { + beforeEach(() => { + resetAppI18nForTest() + vi.restoreAllMocks() + vi.spyOn(api, 'userKBList').mockResolvedValue({ + state: { + version: 1, + categories: [], + files: [], + }, + aiProviderReady: true, + aiProviderMessage: '', + }) + }) + + it('renders zh wording by key', async () => { + setAppLocale('zh') + + const wrapper = mount(MyKnowledgeBaseView, { + global: { + plugins: [createPinia()], + }, + }) + + await flushPromises() + + expect(wrapper.find('h2').text()).toBe('我的知识库') + expect(wrapper.text()).toContain('刷新') + expect(wrapper.text()).toContain('新建分类') + expect(wrapper.text()).toContain('暂无分类,先创建一个再上传。') + }) + + it('renders en wording by key', async () => { + setAppLocale('en') + + const wrapper = mount(MyKnowledgeBaseView, { + global: { + plugins: [createPinia()], + }, + }) + + await flushPromises() + + expect(wrapper.find('h2').text()).toBe('My Knowledge Base') + expect(wrapper.text()).toContain('Refresh') + expect(wrapper.text()).toContain('New Category') + }) +}) diff --git a/frontend/src/__tests__/my-view-codex-mcp.test.ts b/frontend/src/__tests__/my-view-codex-mcp.test.ts new file mode 100644 index 0000000..ce17b55 --- /dev/null +++ b/frontend/src/__tests__/my-view-codex-mcp.test.ts @@ -0,0 +1,66 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})) + +const mountMyView = () => + mount(MyView, { + global: { + plugins: [createPinia()], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + }, + }, + }) + +describe('MyView Codex MCP authorization', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + vi.restoreAllMocks() + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 1, + plan: 'free', + } as any) + vi.spyOn(api, 'detectAIAgents').mockResolvedValue([] as any) + vi.spyOn(api, 'listAgentIdentities').mockResolvedValue([] as any) + }) + + it('labels Codex MCP install as plugin authorization and reuses installMCP', async () => { + vi.spyOn(api, 'detectMCPAgents').mockResolvedValue([ + { + id: 'codex', + name: 'Codex', + detected: true, + installed: false, + configPath: '~/.codex/config.toml', + }, + ] as any) + const installSpy = vi.spyOn(api, 'installMCP').mockResolvedValue({ + installed: [{ id: 'codex', name: 'Codex', path: '~/.codex/config.toml', success: true, accessKey: 'agent_codex_1' }], + } as any) + + const wrapper = mountMyView() + await wrapper.get('[data-testid="my-menu-skill"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="my-agent-approval-policy"]').text()).toContain('Third-party agents cannot approve') + expect(wrapper.get('[data-testid="my-codex-mcp-hint"]').text()).toContain('Codex plugin') + expect(wrapper.get('[data-testid="my-codex-authorize-mcp"]').text()).toBe('Authorize Codex') + + await wrapper.get('[data-testid="my-codex-authorize-mcp"]').trigger('click') + await flushPromises() + + expect(installSpy).toHaveBeenCalledWith(['codex']) + }) +}) diff --git a/frontend/src/__tests__/my-view-menu-i18n.test.ts b/frontend/src/__tests__/my-view-menu-i18n.test.ts new file mode 100644 index 0000000..8e2df3a --- /dev/null +++ b/frontend/src/__tests__/my-view-menu-i18n.test.ts @@ -0,0 +1,69 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia } from 'pinia' +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { getAppLocale, resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('My view menu i18n', () => { + beforeEach(() => { + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 1, + plan: 'free', + } as any) + }) + + it('shows account by default and only renders knowledge base after click', async () => { + const wrapper = mount(MyView, { + global: { + plugins: [createPinia()], + stubs: { + MyKnowledgeBaseView: { + template: '
KB Content
', + }, + }, + }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('Account') + expect(wrapper.text()).toContain('Knowledge Base') + expect(wrapper.text()).toContain('Language') + expect(wrapper.text()).toContain('Settings') + expect(wrapper.find('[data-testid="my-account-panel"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="my-language-panel"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="kb-content"]').exists()).toBe(false) + + await wrapper.get('[data-testid="my-menu-knowledge-base"]').trigger('click') + + expect(wrapper.find('[data-testid="kb-content"]').exists()).toBe(true) + }) + + it('switches locale from language menu', async () => { + const wrapper = mount(MyView, { + global: { + plugins: [createPinia()], + stubs: { + MyKnowledgeBaseView: { + template: '
KB Content
', + }, + }, + }, + }) + await flushPromises() + + expect(getAppLocale()).toBe('en') + + await wrapper.get('[data-testid="my-menu-language"]').trigger('click') + const options = wrapper.findAll('.my-lang-option').map((option) => option.text()) + expect(options).toEqual(['🇺🇸English', '🇨🇳中文', '🇯🇵日本語', '🇪🇸Español', '🇩🇪Deutsch']) + + await wrapper.findAll('.my-lang-option')[4].trigger('click') + + expect(getAppLocale()).toBe('de') + expect(wrapper.text()).toContain(tApp('nav.my')) + }) +}) diff --git a/frontend/src/__tests__/my-view-plan-expired.test.ts b/frontend/src/__tests__/my-view-plan-expired.test.ts new file mode 100644 index 0000000..4b5f7da --- /dev/null +++ b/frontend/src/__tests__/my-view-plan-expired.test.ts @@ -0,0 +1,109 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {}, fullPath: '/my', path: '/my' }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})) + +describe('MyView account panel — expired Pro', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 1, + plan: 'free', + } as any) + }) + + const mountWithLicense = (license: { plan: string; status: string; expiresAt: number }) => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_expired_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() / 1000 + 60, + user: { + id: 'user_expired', + email: 'expired@example.com', + displayName: 'Expired Pro', + avatarUrl: '', + }, + license, + }, + pendingLogin: null, + } as any + authStore.deviceLimit = 1 as any + return mount(MyView, { + global: { + plugins: [pinia], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + AiChatPreferences: { template: '
' }, + }, + }, + }) + } + + it('renders Plan=Free, Status=Pro expired, and a renewal banner when the Pro license is past expiry', async () => { + const wrapper = mountWithLicense({ + plan: 'pro', + status: 'active', + expiresAt: Math.floor(Date.now() / 1000) - 3600, + }) + await flushPromises() + + const planRow = wrapper.find('[data-testid="my-account-plan-row"]') + const statusRow = wrapper.find('[data-testid="my-account-status-row"]') + expect(planRow.text()).toContain(tApp('plan.name.free')) + expect(planRow.text()).not.toContain(tApp('plan.name.pro')) + expect(statusRow.text()).toContain(tApp('my.account.statusValue.proExpired')) + + const banner = wrapper.find('[data-testid="my-account-plan-expired-banner"]') + expect(banner.exists()).toBe(true) + expect(banner.text()).toBe(tApp('my.account.planExpiredBanner')) + + const expiryRow = wrapper.find('[data-testid="my-account-plan-expiry-row"]') + expect(expiryRow.exists()).toBe(true) + expect(expiryRow.text()).toContain(tApp('my.account.expiredOnLabel')) + }) + + it('renders Plan=Pro with an "Expires on" row (not "Expired on") when the Pro license is active', async () => { + const wrapper = mountWithLicense({ + plan: 'pro', + status: 'active', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }) + await flushPromises() + + const planRow = wrapper.find('[data-testid="my-account-plan-row"]') + expect(planRow.text()).toContain(tApp('plan.name.pro')) + expect(wrapper.find('[data-testid="my-account-plan-expired-banner"]').exists()).toBe(false) + + const expiryRow = wrapper.find('[data-testid="my-account-plan-expiry-row"]') + expect(expiryRow.exists()).toBe(true) + expect(expiryRow.text()).toContain(tApp('my.account.expiresLabel')) + expect(expiryRow.text()).not.toContain(tApp('my.account.expiredOnLabel')) + }) + + it('renders plain Free without expired-Pro wording for a Free session', async () => { + const wrapper = mountWithLicense({ plan: 'free', status: 'active', expiresAt: 0 }) + await flushPromises() + + expect(wrapper.find('[data-testid="my-account-plan-expired-banner"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="my-account-plan-expiry-row"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain(tApp('my.account.statusValue.proExpired')) + }) +}) diff --git a/frontend/src/__tests__/my-view-plan-summary.test.ts b/frontend/src/__tests__/my-view-plan-summary.test.ts new file mode 100644 index 0000000..b9cb1c8 --- /dev/null +++ b/frontend/src/__tests__/my-view-plan-summary.test.ts @@ -0,0 +1,86 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {}, fullPath: '/my', path: '/my' }), + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), +})) + +describe('MyView plan summary', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 3, + plan: 'pro', + } as any) + }) + + it('shows the formatted plan name and device limit in the account panel', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + authStore.deviceLimit = 3 as any + + const wrapper = mount(MyView, { + global: { + plugins: [pinia], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + AiChatPreferences: { template: '
' }, + }, + }, + }) + await flushPromises() + + expect(wrapper.text()).toContain(tApp('plan.name.pro')) + expect(wrapper.text()).toContain(tApp('my.account.deviceLimitLabel')) + expect(wrapper.text()).toContain(tApp('my.account.deviceLimitValue', { limit: 3 })) + }) + + it('shows local trial status and expiry for logged-out trial users', async () => { + const authStore = useAuthStore() + const nowSec = Math.floor(Date.now() / 1000) + authStore.state = { + deviceId: 'device_trial', + session: null, + pendingLogin: null, + trial: { startedAt: nowSec - 60, expiresAt: nowSec + 30 * 24 * 60 * 60 }, + } as any + + const wrapper = mount(MyView, { + global: { + plugins: [pinia], + stubs: { + MyKnowledgeBaseView: { template: '
' }, + AiChatPreferences: { template: '
' }, + }, + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="my-account-plan-row"]').text()).toContain(tApp('plan.name.trial')) + expect(wrapper.find('[data-testid="my-account-status-row"]').text()).toContain(tApp('my.account.statusValue.trial')) + expect(wrapper.find('[data-testid="my-account-plan-expiry-row"]').text()).toContain(tApp('my.account.trialExpiresLabel')) + }) +}) diff --git a/frontend/src/__tests__/my-view-settings-export.test.ts b/frontend/src/__tests__/my-view-settings-export.test.ts new file mode 100644 index 0000000..15c30c0 --- /dev/null +++ b/frontend/src/__tests__/my-view-settings-export.test.ts @@ -0,0 +1,58 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import MyView from '@/views/MyView.vue' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' +import { useAppStore } from '@/stores/app' + +describe('MyView settings export', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + vi.restoreAllMocks() + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 1, + plan: 'free', + } as any) + }) + + it('exports logs from settings panel', async () => { + const exportSpy = vi.spyOn(api, 'exportLogs').mockResolvedValue('/tmp/futrixdata-logs.zip') + vi.spyOn(api, 'getDiagnosticsSettings').mockResolvedValue({ datasourceTimingLogEnabled: false }) + vi.spyOn(api, 'setDatasourceTimingLogEnabled').mockResolvedValue({ datasourceTimingLogEnabled: true }) + const wrapper = mount(MyView, { + global: { + plugins: [createPinia()], + stubs: { + MyKnowledgeBaseView: { + template: '
KB Content
', + }, + }, + }, + }) + + await wrapper.get('[data-testid="my-menu-settings"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="my-settings-panel"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="ai-default-open"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Downloads folder when available; otherwise your home folder') + expect(wrapper.text()).toContain('Datasource timing diagnostics') + expect(wrapper.text()).toContain('Bundle runtime logs for troubleshooting review.') + await wrapper.get('[data-testid="my-settings-datasource-timing-switch"]').trigger('click') + await flushPromises() + + expect(api.setDatasourceTimingLogEnabled).toHaveBeenCalledWith(true) + expect(useAppStore().notice.message).toContain('Datasource timing diagnostics enabled') + + await wrapper.get('[data-testid="my-settings-export-logs"]').trigger('click') + await flushPromises() + + expect(exportSpy).toHaveBeenCalledTimes(1) + expect(useAppStore().notice.message).toContain('Logs exported') + }) +}) diff --git a/frontend/src/__tests__/mysql-index-ordering.test.ts b/frontend/src/__tests__/mysql-index-ordering.test.ts new file mode 100644 index 0000000..cfa2831 --- /dev/null +++ b/frontend/src/__tests__/mysql-index-ordering.test.ts @@ -0,0 +1,43 @@ +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/services/api', () => ({ + api: { + describeEntity: vi.fn(), + }, +})) + +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { useEntityDetails } from '@/views/console/composables/useEntityDetails' + +describe('mysql index ordering', () => { + it('renders PRIMARY first, then unique indexes, then remaining indexes', async () => { + setActivePinia(createPinia()) + + const store = useAppStore() + store.setCurrentDatasource({ + id: 'ds-1', + name: 'mysql', + type: 'mysql', + host: 'localhost', + port: 3306, + } as any) + + ;(api as any).describeEntity.mockResolvedValue({ + columns: [], + indexes: [ + { name: 'uq_2', unique: true, column: 'b' }, + { name: 'idx_1', unique: false, column: 'c' }, + { name: 'PRIMARY', unique: true, column: 'id' }, + { name: 'uq_1', unique: true, column: 'a' }, + ], + details: [], + }) + + const { fetchEntityDetails } = useEntityDetails({ markActive: () => {} }) + const detail = await fetchEntityDetails('t') + + expect(detail.indexes.map((idx) => idx.name)).toEqual(['PRIMARY', 'uq_2', 'uq_1', 'idx_1']) + }) +}) diff --git a/frontend/src/__tests__/new-manual-agent-dialog.test.ts b/frontend/src/__tests__/new-manual-agent-dialog.test.ts new file mode 100644 index 0000000..a830da1 --- /dev/null +++ b/frontend/src/__tests__/new-manual-agent-dialog.test.ts @@ -0,0 +1,321 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import NewManualAgentDialog from '@/components/skill/NewManualAgentDialog.vue' +import { api } from '@/services/api' +import type { AgentIdentity } from '@/services/api/skill' + +describe('NewManualAgentDialog', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not call createManualAgent if the user cancels stage 1', async () => { + const createSpy = vi.spyOn(api, 'createManualAgent') + const infoSpy = vi.spyOn(api, 'getManualInstallInfoForKey') + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + expect(wrapper.find('[data-testid="new-manual-agent-approval-policy"]').text()).toContain('Third-party agents cannot approve') + + await wrapper.find('[data-testid="new-manual-agent-cancel"]').trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(infoSpy).not.toHaveBeenCalled() + expect(wrapper.emitted('close')).toBeTruthy() + expect(wrapper.emitted('created')).toBeFalsy() + }) + + it('submitting stage 1 mints an identity, fetches the snippet, and emits created', async () => { + const created = { + accessKey: 'agent_new_1234', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-04-25T10:10:11Z', + updatedAt: '2026-04-25T10:10:11Z', + } + const createSpy = vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + const infoSpy = vi.spyOn(api, 'getManualInstallInfoForKey').mockResolvedValue({ + cliBinaryPath: '/usr/local/bin/futrixdata-cli', + accessKey: 'agent_new_1234', + agentName: 'zed-research', + skillTemplates: [ + { + id: 'claude', + name: 'Claude Code', + filename: 'SKILL.md', + suggestedPath: '~/.claude/skills/futrixdata/SKILL.md', + content: 'futrixdata-cli --agent-access-key agent_new_1234 tool list --json', + }, + ], + mcpSnippets: [ + { + id: 'cursor', + label: 'Cursor', + format: 'json', + content: '{ "mcpServers": { "futrixdata": { "command": "futrixdata-cli" } } }', + suggestedPath: '~/.cursor/mcp.json', + configKey: 'mcpServers.futrixdata', + }, + ], + } as any) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + expect(createSpy).toHaveBeenCalledWith('zed-research') + expect(infoSpy).toHaveBeenCalledWith('agent_new_1234') + + const createdEvents = wrapper.emitted('created') + expect(createdEvents).toBeTruthy() + expect(createdEvents?.[0]?.[0]).toMatchObject({ accessKey: 'agent_new_1234' }) + + // Stage 2 — snippet view should now show the access key + skill snippet. + const dialogText = wrapper.text() + expect(dialogText).toContain('agent_new_1234') + expect(wrapper.find('[data-testid="new-manual-agent-snippet-approval-policy"]').text()).toContain('Third-party agents cannot approve') + expect(wrapper.find('[data-testid="new-manual-agent-skill-section"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="new-manual-mcp-cursor"]').exists()).toBe(true) + }) + + it('surfaces a stage-1 error inline when CreateManualAgent fails', async () => { + vi.spyOn(api, 'createManualAgent').mockRejectedValue(new Error('store unavailable')) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + const error = wrapper.find('[data-testid="new-manual-agent-error"]') + expect(error.exists()).toBe(true) + expect(error.text()).toContain('store unavailable') + expect(wrapper.emitted('created')).toBeFalsy() + }) + + it('renders an inline stage-2 error when GetManualInstallInfoForKey fails after the identity is minted', async () => { + const created = { + accessKey: 'agent_new_5678', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-04-25T10:10:11Z', + updatedAt: '2026-04-25T10:10:11Z', + } + vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + vi.spyOn(api, 'getManualInstallInfoForKey').mockRejectedValue(new Error('snippet store offline')) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + expect(wrapper.emitted('created')).toBeTruthy() + expect(wrapper.find('[data-testid="new-manual-agent-summary"]').exists()).toBe(true) + const infoError = wrapper.find('[data-testid="new-manual-agent-info-error"]') + expect(infoError.exists()).toBe(true) + expect(infoError.text()).toContain('snippet store offline') + // Stage-1-only error slot must stay clean — error belongs to stage 2. + expect(wrapper.find('[data-testid="new-manual-agent-error"]').exists()).toBe(false) + }) + + it('skips the sensitivity grant call when the checkbox is unchecked', async () => { + const created = { + accessKey: 'agent_grant_unchecked', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-05-02T08:58:55Z', + updatedAt: '2026-05-02T08:58:55Z', + } + vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + vi.spyOn(api, 'getManualInstallInfoForKey').mockResolvedValue({ + cliBinaryPath: '', + accessKey: created.accessKey, + agentName: created.name, + skillTemplates: [], + mcpSnippets: [], + } as any) + const grantSpy = vi.spyOn(api, 'setAgentSensitivityGrant') + const datasourceGrantSpy = vi.spyOn(api, 'setAgentDatasourceManagementGrant') + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + expect(grantSpy).not.toHaveBeenCalled() + expect(datasourceGrantSpy).not.toHaveBeenCalled() + const created0 = wrapper.emitted('created')?.[0]?.[0] as AgentIdentity | undefined + expect(created0?.sensitivityClassificationGrant).toBeFalsy() + }) + + it('applies the datasource management grant after creation when checked', async () => { + const created = { + accessKey: 'agent_datasource_grant_checked', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-05-02T08:58:55Z', + updatedAt: '2026-05-02T08:58:55Z', + } + vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + vi.spyOn(api, 'getManualInstallInfoForKey').mockResolvedValue({ + cliBinaryPath: '', + accessKey: created.accessKey, + agentName: created.name, + skillTemplates: [], + mcpSnippets: [], + } as any) + const grantSpy = vi + .spyOn(api, 'setAgentDatasourceManagementGrant') + .mockResolvedValue({ ...created, datasourceManagementGrant: true } as any) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-datasource-grant-input"]').setValue(true) + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + expect(grantSpy).toHaveBeenCalledWith('agent_datasource_grant_checked', true) + const created0 = wrapper.emitted('created')?.[0]?.[0] as AgentIdentity | undefined + expect(created0?.datasourceManagementGrant).toBe(true) + }) + + it('applies the sensitivity grant after creation when the checkbox is checked', async () => { + const created = { + accessKey: 'agent_grant_checked', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-05-02T08:58:55Z', + updatedAt: '2026-05-02T08:58:55Z', + } + vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + vi.spyOn(api, 'getManualInstallInfoForKey').mockResolvedValue({ + cliBinaryPath: '', + accessKey: created.accessKey, + agentName: created.name, + skillTemplates: [], + mcpSnippets: [], + } as any) + const grantSpy = vi + .spyOn(api, 'setAgentSensitivityGrant') + .mockResolvedValue({ ...created, sensitivityClassificationGrant: true } as any) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-grant-input"]').setValue(true) + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + expect(grantSpy).toHaveBeenCalledWith('agent_grant_checked', true) + // Parent must observe the granted state — otherwise the management UI + // re-renders the new card as "Not granted" until the next list refresh. + const created0 = wrapper.emitted('created')?.[0]?.[0] as AgentIdentity | undefined + expect(created0?.sensitivityClassificationGrant).toBe(true) + }) + + it('still advances to stage 2 with a banner when the post-create grant write fails', async () => { + const created = { + accessKey: 'agent_grant_failed', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-05-02T08:58:55Z', + updatedAt: '2026-05-02T08:58:55Z', + } + vi.spyOn(api, 'createManualAgent').mockResolvedValue(created as any) + vi.spyOn(api, 'getManualInstallInfoForKey').mockResolvedValue({ + cliBinaryPath: '', + accessKey: created.accessKey, + agentName: created.name, + skillTemplates: [], + mcpSnippets: [], + } as any) + vi.spyOn(api, 'setAgentSensitivityGrant').mockRejectedValue(new Error('disk full')) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-grant-input"]').setValue(true) + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + await flushPromises() + + // Stage 2 must still render — the agent IS created and the user needs the snippets. + expect(wrapper.find('[data-testid="new-manual-agent-summary"]').exists()).toBe(true) + // Visible banner must surface the failure rather than hiding it behind the + // happy snippet view. Otherwise the user assumes "granted" silently. + const grantErr = wrapper.find('[data-testid="new-manual-agent-grant-error"]') + expect(grantErr.exists()).toBe(true) + expect(grantErr.text()).toContain('disk full') + // The emitted identity must NOT claim the grant landed. + const created0 = wrapper.emitted('created')?.[0]?.[0] as AgentIdentity | undefined + expect(created0?.sensitivityClassificationGrant).toBeFalsy() + }) + + it('blocks close paths while the create call is in flight', async () => { + let release!: (value: AgentIdentity) => void + const pending = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(api, 'createManualAgent').mockReturnValue(pending as any) + + const wrapper = mount(NewManualAgentDialog, { + global: { plugins: [createPinia()] }, + }) + + await wrapper.find('[data-testid="new-manual-agent-name-input"]').setValue('zed-research') + await wrapper.find('[data-testid="new-manual-agent-form"]').trigger('submit') + // Yield so the submit handler enters its await. + await Promise.resolve() + + const closeBtn = wrapper.find('[data-testid="new-manual-agent-close"]') + expect(closeBtn.attributes('disabled')).toBeDefined() + await closeBtn.trigger('click') + await wrapper.find('[data-testid="new-manual-agent-dialog"]').trigger('keydown', { key: 'Escape' }) + expect(wrapper.emitted('close')).toBeFalsy() + + release({ + accessKey: 'agent_new_9999', + name: 'zed-research', + agentType: 'manual', + source: 'manual', + createdAt: '2026-04-25T10:10:11Z', + updatedAt: '2026-04-25T10:10:11Z', + } as AgentIdentity) + await flushPromises() + // After resolution, busy is false; close is unblocked again. + expect(wrapper.find('[data-testid="new-manual-agent-close"]').attributes('disabled')).toBeUndefined() + }) +}) diff --git a/frontend/src/__tests__/plan-effective-entitlement.test.ts b/frontend/src/__tests__/plan-effective-entitlement.test.ts new file mode 100644 index 0000000..f039d86 --- /dev/null +++ b/frontend/src/__tests__/plan-effective-entitlement.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' + +import { evaluateLicense, effectivePlanFor } from '@/modules/plan/limits' + +const NOW_MS = 1_800_000_000_000 // 2027-01-15T08:00:00Z, well past most fixtures + +describe('evaluateLicense', () => { + it('keeps active Pro as Pro', () => { + const ent = evaluateLicense( + { plan: 'pro', status: 'active', expiresAt: Math.floor(NOW_MS / 1000) + 86_400 }, + NOW_MS, + ) + expect(ent.effectivePlan).toBe('pro') + expect(ent.effectiveStatus).toBe('active') + expect(ent.rawPlan).toBe('pro') + }) + + it('treats status=expired as Free with pro_expired status', () => { + const ent = evaluateLicense({ plan: 'pro', status: 'expired', expiresAt: 0 }, NOW_MS) + expect(ent.effectivePlan).toBe('free') + expect(ent.effectiveStatus).toBe('pro_expired') + expect(ent.rawPlan).toBe('pro') + }) + + it('treats past expiresAt on Pro as Free with pro_expired status even when status=active', () => { + const ent = evaluateLicense( + { plan: 'pro', status: 'active', expiresAt: Math.floor(NOW_MS / 1000) - 60 }, + NOW_MS, + ) + expect(ent.effectivePlan).toBe('free') + expect(ent.effectiveStatus).toBe('pro_expired') + }) + + it('leaves Free unchanged', () => { + const ent = evaluateLicense({ plan: 'free', status: 'active', expiresAt: 0 }, NOW_MS) + expect(ent.effectivePlan).toBe('free') + expect(ent.effectiveStatus).toBe('free') + }) + + it('maps null/undefined license to Free', () => { + expect(evaluateLicense(null, NOW_MS).effectivePlan).toBe('free') + expect(evaluateLicense(undefined, NOW_MS).effectivePlan).toBe('free') + }) + + it('treats active local trial as Pro with trial status', () => { + const ent = evaluateLicense( + null, + NOW_MS, + { startedAt: Math.floor(NOW_MS / 1000) - 60, expiresAt: Math.floor(NOW_MS / 1000) + 86_400 }, + ) + expect(ent.effectivePlan).toBe('pro') + expect(ent.effectiveStatus).toBe('trial') + expect(ent.trialExpiresAt).toBe(Math.floor(NOW_MS / 1000) + 86_400) + }) + + it('falls back to Free after local trial expires', () => { + const ent = evaluateLicense( + null, + NOW_MS, + { startedAt: Math.floor(NOW_MS / 1000) - 86_400 * 31, expiresAt: Math.floor(NOW_MS / 1000) - 60 }, + ) + expect(ent.effectivePlan).toBe('free') + expect(ent.effectiveStatus).toBe('free') + }) + + it('lets active Pro keep active status even when local trial exists', () => { + const ent = evaluateLicense( + { plan: 'pro', status: 'active', expiresAt: 0 }, + NOW_MS, + { startedAt: Math.floor(NOW_MS / 1000) - 60, expiresAt: Math.floor(NOW_MS / 1000) + 86_400 }, + ) + expect(ent.effectivePlan).toBe('pro') + expect(ent.effectiveStatus).toBe('active') + }) + + it('does not treat zero expiresAt as expired', () => { + const ent = evaluateLicense({ plan: 'pro', status: 'active', expiresAt: 0 }, NOW_MS) + expect(ent.effectivePlan).toBe('pro') + expect(ent.effectiveStatus).toBe('active') + }) +}) + +describe('effectivePlanFor', () => { + it('returns the resolved plan string', () => { + expect(effectivePlanFor({ plan: 'pro', status: 'expired', expiresAt: 0 }, NOW_MS)).toBe('free') + expect(effectivePlanFor({ plan: 'pro', status: 'active', expiresAt: 0 }, NOW_MS)).toBe('pro') + }) +}) diff --git a/frontend/src/__tests__/plan-limit-errors.test.ts b/frontend/src/__tests__/plan-limit-errors.test.ts new file mode 100644 index 0000000..813a195 --- /dev/null +++ b/frontend/src/__tests__/plan-limit-errors.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' +import { normalizePlan, resolvePlanLimitMessage } from '@/modules/plan/limits' + +describe('plan limit error translation', () => { + beforeEach(() => { + resetAppI18nForTest() + setAppLocale('en') + }) + + it('translates datasource limit errors into plan-aware upgrade wording', () => { + expect(resolvePlanLimitMessage('plan_limit_exceeded:datasources:free:3', undefined)).toBe( + tApp('plan.notice.datasourceLimit', { plan: tApp('plan.name.free'), limit: 3 }), + ) + }) + + it('translates custom risk rule limits into pro upgrade wording', () => { + expect(resolvePlanLimitMessage('plan_limit_exceeded:risk_rules:free:0', undefined)).toBe( + tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') }), + ) + }) + + it('translates device limit errors into a device-management hint', () => { + expect(resolvePlanLimitMessage('plan_limit_exceeded:devices:free:1', undefined)).toBe( + tApp('plan.notice.deviceLimit', { plan: tApp('plan.name.free'), limit: 1 }), + ) + }) + + it('keeps missing plan values unknown but normalizes unknown strings to free', () => { + expect(normalizePlan(undefined)).toBeNull() + expect(normalizePlan(null)).toBeNull() + expect(normalizePlan('enterprise')).toBe('free') + expect(normalizePlan('')).toBe('free') + }) +}) diff --git a/frontend/src/__tests__/plan-limits-datasource.test.ts b/frontend/src/__tests__/plan-limits-datasource.test.ts new file mode 100644 index 0000000..5268eef --- /dev/null +++ b/frontend/src/__tests__/plan-limits-datasource.test.ts @@ -0,0 +1,93 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import DatasourceListView from '@/views/DatasourceListView.vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const pushMock = vi.fn() +let routeState: any = { name: 'datasource-create', params: {} } + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +describe('Free/Pro datasource limits', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + routeState = { name: 'datasource-create', params: {} } + pushMock.mockReset() + vi.spyOn(api, 'listDatasources').mockResolvedValue([]) + vi.spyOn(api, 'listAIConfigs').mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const seedFreePlanWithThreeDatasources = () => { + const appStore = useAppStore() + const authStore = useAuthStore() + authStore.state.session = { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Plan User', + avatarUrl: '', + }, + license: { + plan: 'free', + status: 'active', + expiresAt: 0, + }, + } as any + appStore.datasources = [ + { id: 'ds_1', name: 'One', type: 'mysql', host: '127.0.0.1', port: 3306, username: '', password: '', options: {} } as any, + { id: 'ds_2', name: 'Two', type: 'postgresql', host: '127.0.0.1', port: 5432, username: '', password: '', options: {} } as any, + { id: 'ds_3', name: 'Three', type: 'redis', host: '127.0.0.1', port: 6379, username: '', password: '', options: {} } as any, + ] + return { appStore, authStore } + } + + it('blocks opening the create flow when free plan already has three datasources', async () => { + const { appStore } = seedFreePlanWithThreeDatasources() + + const wrapper = mount(DatasourceListView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('datasource.list.new'))!.trigger('click') + + expect(pushMock).not.toHaveBeenCalled() + expect(appStore.notice.message).toContain('3') + }) + + it('blocks saving a fourth datasource for free plan users', async () => { + const { appStore } = seedFreePlanWithThreeDatasources() + const createSpy = vi.spyOn(api, 'createDatasource').mockResolvedValue({ id: 'ds_4' } as any) + + const wrapper = mount(DatasourceFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find('#ds-name').setValue('Four') + await wrapper.find('#ds-host').setValue('127.0.0.1') + await wrapper.find('#ds-username').setValue('root') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.save'))!.trigger('click') + await flushPromises() + + expect(createSpy).not.toHaveBeenCalled() + expect(appStore.notice.message).toContain('3') + }) +}) diff --git a/frontend/src/__tests__/plan-limits-expired-pro.test.ts b/frontend/src/__tests__/plan-limits-expired-pro.test.ts new file mode 100644 index 0000000..7b85386 --- /dev/null +++ b/frontend/src/__tests__/plan-limits-expired-pro.test.ts @@ -0,0 +1,172 @@ +import { setActivePinia, createPinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { api } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import { + canManageCustomRiskRules, + hasReachedDatasourceLimit, + datasourceLimitForPlan, + deviceLimitForPlan, +} from '@/modules/plan/limits' + +describe('expired-Pro gating via authStore.effectivePlan', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + const seedExpiredProSession = () => { + const auth = useAuthStore() + auth.state.session = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() / 1000 + 60, + user: { id: 'u1', email: 'e@x', displayName: 'E', avatarUrl: '' }, + license: { plan: 'pro', status: 'expired', expiresAt: 0 }, + } as any + return auth + } + + it('treats expired Pro as Free for datasource limit (3) while keeping signed-in custom risk rules available', () => { + const auth = seedExpiredProSession() + + expect(auth.effectivePlan).toBe('free') + expect(auth.effectiveLicense.effectiveStatus).toBe('pro_expired') + + expect(hasReachedDatasourceLimit(auth.effectivePlan, 3)).toBe(true) + expect(datasourceLimitForPlan(auth.effectivePlan)).toBe(3) + expect(canManageCustomRiskRules(auth.effectivePlan, { isAuthenticated: auth.isAuthenticated })).toBe(true) + expect(deviceLimitForPlan(auth.effectivePlan)).toBe(1) + }) + + it('keeps active Pro gating', () => { + const auth = useAuthStore() + auth.state.session = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() / 1000 + 60, + user: { id: 'u2', email: 'p@x', displayName: 'P', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: Math.floor(Date.now() / 1000) + 3600 }, + } as any + + expect(auth.effectivePlan).toBe('pro') + expect(hasReachedDatasourceLimit(auth.effectivePlan, 50)).toBe(false) + expect(canManageCustomRiskRules(auth.effectivePlan, { isAuthenticated: auth.isAuthenticated })).toBe(true) + expect(deviceLimitForPlan(auth.effectivePlan)).toBe(3) + }) + + it('treats no session as Free for local-use limits', () => { + const auth = useAuthStore() + expect(auth.effectivePlan).toBe('free') + expect(canManageCustomRiskRules(auth.effectivePlan, { isAuthenticated: auth.isAuthenticated })).toBe(false) + expect(hasReachedDatasourceLimit(auth.effectivePlan, 3)).toBe(true) + }) + + it('treats logged-out active local trial as Pro for local-use gates', () => { + const auth = useAuthStore() + const nowSec = Math.floor(Date.now() / 1000) + auth.state.trial = { startedAt: nowSec - 60, expiresAt: nowSec + 30 * 24 * 60 * 60 } + + expect(auth.effectivePlan).toBe('pro') + expect(auth.effectiveLicense.effectiveStatus).toBe('trial') + expect(hasReachedDatasourceLimit(auth.effectivePlan, 3)).toBe(false) + expect(canManageCustomRiskRules(auth.effectivePlan, { isAuthenticated: auth.isAuthenticated })).toBe(true) + expect(deviceLimitForPlan(auth.effectivePlan)).toBe(3) + }) + + it('loads local trial state when restore gets a login-required response', async () => { + const auth = useAuthStore() + const nowSec = Math.floor(Date.now() / 1000) + const trial = { startedAt: nowSec - 60, expiresAt: nowSec + 30 * 24 * 60 * 60 } + vi.spyOn(api, 'ensureAuthenticated').mockRejectedValue(new Error('login required')) + vi.spyOn(api, 'currentAuth').mockResolvedValue({ + deviceId: 'device_trial', + session: null, + pendingLogin: null, + trial, + } as any) + + await auth.restore() + + expect(api.currentAuth).toHaveBeenCalled() + expect(auth.state.trial).toEqual(trial) + expect(auth.effectivePlan).toBe('pro') + expect(auth.effectiveLicense.effectiveStatus).toBe('trial') + expect(auth.error).toBe('') + }) + + it('reconciles the local license when loadDevices returns a fresh license', async () => { + const auth = useAuthStore() + auth.state.session = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() / 1000 + 60, + user: { id: 'u3', email: 'x@x', displayName: 'X', avatarUrl: '' }, + // Pretend the local copy is stale: still says Pro/active. + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + } as any + + expect(auth.effectivePlan).toBe('pro') + + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 1, + plan: 'free', + license: { plan: 'pro', status: 'expired', expiresAt: 0 }, + } as any) + + await auth.loadDevices() + + expect(auth.effectivePlan).toBe('free') + expect(auth.effectiveLicense.effectiveStatus).toBe('pro_expired') + }) + + // Regression for PR #451 r3233813711: effective entitlement must recompute + // when the clock crosses expiresAt even without a license/state mutation, + // otherwise the UI keeps showing Pro after expiry while backend gates + // already block. + it('recomputes effectivePlan when the reactive clock crosses expiresAt', () => { + const auth = useAuthStore() + const nowSec = Math.floor(Date.now() / 1000) + const expiresAt = nowSec + 60 + auth.state.session = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: nowSec + 3600, + user: { id: 'u-clock', email: 'c@x', displayName: 'C', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt }, + } as any + // Pin the reactive clock to "before expiry" first. + auth.__setNowForTest((expiresAt - 30) * 1000) + expect(auth.effectivePlan).toBe('pro') + expect(auth.effectiveLicense.effectiveStatus).toBe('active') + + // Now advance past expiry without touching the license — only the clock + // changes. The computed must invalidate. + auth.__setNowForTest((expiresAt + 30) * 1000) + expect(auth.effectivePlan).toBe('free') + expect(auth.effectiveLicense.effectiveStatus).toBe('pro_expired') + }) + + it('leaves the local license untouched when loadDevices omits license context', async () => { + const auth = useAuthStore() + const baseLicense = { plan: 'pro', status: 'active', expiresAt: 0 } + auth.state.session = { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() / 1000 + 60, + user: { id: 'u4', email: 'y@x', displayName: 'Y', avatarUrl: '' }, + license: { ...baseLicense }, + } as any + + vi.spyOn(api, 'listAuthDevices').mockResolvedValue({ + devices: [], + limit: 3, + plan: 'pro', + } as any) + + await auth.loadDevices() + + expect(auth.currentLicense).toMatchObject(baseLicense) + }) +}) diff --git a/frontend/src/__tests__/plan-limits-risk-rules.test.ts b/frontend/src/__tests__/plan-limits-risk-rules.test.ts new file mode 100644 index 0000000..2defca3 --- /dev/null +++ b/frontend/src/__tests__/plan-limits-risk-rules.test.ts @@ -0,0 +1,181 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import RiskRulesFormView from '@/views/RiskRulesFormView.vue' +import RiskRulesView from '@/views/RiskRulesView.vue' +import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +const pushMock = vi.fn() +let routeState: any = { name: 'risk-rules-create', params: {} } + +const listRulesMock = vi.fn(() => []) +const listUserRulesMock = vi.fn(() => []) +const addRuleMock = vi.fn() +const updateRuleMock = vi.fn() +const deleteRuleMock = vi.fn() +const updateBuiltinProbeRuleThresholdsMock = vi.fn() +const listEntitiesMock = vi.fn(() => []) + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +vi.mock('@wailsjs/go/main/App', () => ({ + RiskEngineListRules: () => listRulesMock(), + RiskEngineListUserRules: () => listUserRulesMock(), + RiskEngineAddRule: (...args: any[]) => addRuleMock(...args), + RiskEngineUpdateRule: (...args: any[]) => updateRuleMock(...args), + RiskEngineDeleteRule: (...args: any[]) => deleteRuleMock(...args), + RiskEngineSetBuiltinEnabled: vi.fn(), + RiskEngineSetEnabled: vi.fn(), + RiskEngineUpdateBuiltinProbeRuleThresholds: (...args: any[]) => updateBuiltinProbeRuleThresholdsMock(...args), + ListEntities: (...args: any[]) => listEntitiesMock(...args), +})) + +vi.mock('@wailsjs/go/models', () => ({ + riskengine: { + Rule: class Rule { + id = '' + builtin = false + enabled = true + constructor(input: any = {}) { + Object.assign(this, input) + } + }, + RuleCondition: class RuleCondition { + constructor(input: any = {}) { + Object.assign(this, input) + } + }, + RuleThresholds: class RuleThresholds { + constructor(input: any = {}) { + Object.assign(this, input) + } + }, + RuleScope: class RuleScope { + constructor(input: any = {}) { + Object.assign(this, input) + } + }, + }, +})) + +describe('Free/Pro custom risk-rule limits', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + routeState = { name: 'risk-rules-create', params: {} } + pushMock.mockReset() + listRulesMock.mockReset() + listUserRulesMock.mockReset() + addRuleMock.mockReset() + updateRuleMock.mockReset() + deleteRuleMock.mockReset() + updateBuiltinProbeRuleThresholdsMock.mockReset() + listEntitiesMock.mockReset() + listRulesMock.mockReturnValue([]) + listUserRulesMock.mockReturnValue([]) + + const authStore = useAuthStore() + authStore.state.session = { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Plan User', + avatarUrl: '', + }, + license: { + plan: 'free', + status: 'active', + expiresAt: 0, + }, + } as any + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('allows free plan users to open custom risk-rule create and export actions', async () => { + const appStore = useAppStore() + const wrapper = mount(RiskRulesView, { global: { plugins: [pinia] } }) + await flushPromises() + + const buttons = wrapper.findAll('button') + await buttons.find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + await buttons.find((btn) => btn.text() === tApp('riskRules.export'))!.trigger('click') + + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules-create' }) + expect(appStore.notice.message.toLowerCase()).not.toContain('pro') + }) + + it('allows free plan users to save a custom risk rule', async () => { + const appStore = useAppStore() + const wrapper = mount(RiskRulesFormView, { global: { plugins: [pinia] } }) + await flushPromises() + + await wrapper.find(`input[placeholder="${tApp('riskRules.form.ruleNameHint')}"]`).setValue('Free Rule') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(addRuleMock).toHaveBeenCalledTimes(1) + expect(updateRuleMock).not.toHaveBeenCalled() + expect(appStore.notice.message.toLowerCase()).not.toContain('pro') + }) + + it('blocks logged-out users from opening custom risk-rule entry', async () => { + const authStore = useAuthStore() + authStore.state.session = null as any + const appStore = useAppStore() + + const wrapper = mount(RiskRulesView, { global: { plugins: [pinia] } }) + await flushPromises() + + const buttons = wrapper.findAll('button') + await buttons.find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + + expect(pushMock).not.toHaveBeenCalled() + expect(appStore.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + }) + + it('allows unknown non-empty signed-in plan values through the same custom risk-rule gate as free', async () => { + const authStore = useAuthStore() + authStore.state.session = { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { + id: 'user_1', + email: 'user@example.com', + displayName: 'Plan User', + avatarUrl: '', + }, + license: { + plan: 'enterprise', + status: 'active', + expiresAt: 0, + }, + } as any + + const appStore = useAppStore() + const wrapper = mount(RiskRulesView, { global: { plugins: [pinia] } }) + await flushPromises() + + const buttons = wrapper.findAll('button') + await buttons.find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules-create' }) + expect(appStore.notice.message).not.toBe(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) +}) diff --git a/frontend/src/__tests__/redis-command-docs.test.ts b/frontend/src/__tests__/redis-command-docs.test.ts new file mode 100644 index 0000000..d272ede --- /dev/null +++ b/frontend/src/__tests__/redis-command-docs.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' + +import { + clearRedisCommandDocsCache, + formatRedisCommandSyntax, + getRedisCommandCompletion, + getRedisCommandSuggestions, + getRedisInlineHint, + loadRedisCommandDocs, + refreshRedisCommandDocs, +} from '../modules/redis/command-docs' + +describe('redis command docs', () => { + it('renders SET syntax with optional args', () => { + const docs = { + updatedAt: 0, + commands: { + SET: { + arguments: [ + { name: 'key', type: 'key', display_text: 'key' }, + { name: 'value', type: 'string', display_text: 'value' }, + { + name: 'condition', + type: 'oneof', + optional: true, + arguments: [ + { name: 'nx', type: 'pure-token', token: 'NX' }, + { name: 'xx', type: 'pure-token', token: 'XX' }, + ], + }, + { name: 'get', type: 'pure-token', token: 'GET', optional: true }, + { + name: 'expiration', + type: 'oneof', + optional: true, + arguments: [ + { name: 'seconds', type: 'integer', token: 'EX', display_text: 'seconds' }, + { name: 'milliseconds', type: 'integer', token: 'PX', display_text: 'milliseconds' }, + { name: 'unix-time-seconds', type: 'unix-time', token: 'EXAT', display_text: 'unix-time-seconds' }, + { + name: 'unix-time-milliseconds', + type: 'unix-time', + token: 'PXAT', + display_text: 'unix-time-milliseconds', + }, + { name: 'keepttl', type: 'pure-token', token: 'KEEPTTL' }, + ], + }, + ], + }, + }, + } + + const syntax = formatRedisCommandSyntax('set', docs) + + expect(syntax).toBe( + 'SET key value [NX|XX] [GET] [EX seconds|PX milliseconds|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL]', + ) + }) + + it('normalizes lowercase command keys on refresh', async () => { + clearRedisCommandDocsCache() + const base = loadRedisCommandDocs() + const nextDocs = { + updatedAt: base.updatedAt + 1000, + commands: { + set: { + arguments: [ + { name: 'key', type: 'key', display_text: 'key' }, + { name: 'value', type: 'string', display_text: 'value' }, + ], + }, + }, + } + + await refreshRedisCommandDocs('ds_redis', async () => nextDocs) + + const updated = loadRedisCommandDocs() + expect(updated.commands.SET).toBeDefined() + expect(formatRedisCommandSyntax('set', updated)).toBe('SET key value') + }) + + it('refreshes cached docs asynchronously', async () => { + clearRedisCommandDocsCache() + const base = loadRedisCommandDocs() + const nextDocs = { + updatedAt: base.updatedAt + 1000, + commands: { + PING: { arguments: [] }, + }, + } + + await refreshRedisCommandDocs('ds_1', async () => nextDocs) + + const updated = loadRedisCommandDocs() + expect(updated.updatedAt).toBe(nextDocs.updatedAt) + expect(updated.commands.PING).toBeDefined() + }) + + it('builds inline completion suffix', () => { + const docs = { + updatedAt: 0, + commands: { + SET: { + arguments: [ + { name: 'key', type: 'key', display_text: 'key' }, + { name: 'value', type: 'string', display_text: 'value' }, + ], + }, + }, + } + + const completion = getRedisCommandCompletion('SET', docs) + + expect(completion).toBe(' key value') + + const completionAfterKey = getRedisCommandCompletion('SET abc', docs) + + expect(completionAfterKey).toBe(' value') + }) + + it('returns inline hint only when caret at end', () => { + const docs = { + updatedAt: 0, + commands: { + SET: { + arguments: [ + { name: 'key', type: 'key', display_text: 'key' }, + { name: 'value', type: 'string', display_text: 'value' }, + ], + }, + }, + } + + const hintAtEnd = getRedisInlineHint('SET', 3, 3, docs) + expect(hintAtEnd?.suffix).toBe(' key value') + + const hintMid = getRedisInlineHint('SET', 1, 1, docs) + expect(hintMid).toBeNull() + + const hintAfterKey = getRedisInlineHint('SET abc', 7, 7, docs) + expect(hintAfterKey?.suffix).toBe(' value') + }) + + it('returns Redis command suggestions from a command prefix', () => { + const docs = { + updatedAt: 0, + commands: { + SET: { + summary: 'Set the string value of a key.', + arguments: [ + { name: 'key', type: 'key', display_text: 'key' }, + { name: 'value', type: 'string', display_text: 'value' }, + ], + }, + SETBIT: { summary: 'Sets or clears the bit at offset.' }, + GET: { summary: 'Get the value of a key.' }, + }, + } + + const suggestions = getRedisCommandSuggestions('set', docs) + + expect(suggestions.map((item) => item.command)).toEqual(['SET', 'SETBIT']) + expect(suggestions[0].syntax).toBe('SET key value') + expect(getRedisCommandSuggestions('set ', docs)).toEqual([]) + expect(getRedisCommandSuggestions('set key', docs)).toEqual([]) + }) + + it('includes official module commands in defaults', () => { + clearRedisCommandDocsCache() + const docs = loadRedisCommandDocs() + + expect(docs.commands['JSON.GET']).toBeDefined() + expect(docs.commands['FT.SEARCH']).toBeDefined() + expect(docs.commands['TS.ADD']).toBeDefined() + expect(docs.commands['BF.ADD']).toBeDefined() + expect(docs.commands['GRAPH.QUERY']).toBeDefined() + expect(docs.commands['AI.TENSORSET']).toBeDefined() + }) +}) diff --git a/frontend/src/__tests__/redis-console-inspector.test.ts b/frontend/src/__tests__/redis-console-inspector.test.ts new file mode 100644 index 0000000..465c58d --- /dev/null +++ b/frontend/src/__tests__/redis-console-inspector.test.ts @@ -0,0 +1,97 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +const Dummy = { template: '
' } + +describe('Redis console inspector (prototype UI)', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + ], + }) + + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['sample_key'], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'short preview', + truncated: true, + }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('selects a key, shows header, and expands full preview into code view', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('sample') + await new Promise((resolve) => setTimeout(resolve, 300)) + await flushPromises() + + const keyList = wrapper.get('#key-list') + const keyRow = keyList.findAll('button').find((btn) => btn.text().includes('sample_key')) + expect(keyRow).toBeTruthy() + await keyRow!.trigger('click') + await flushPromises() + + expect(wrapper.get('#active-key-title').text()).toContain('sample_key') + expect(wrapper.get('#active-key-type').text()).toContain('STR') + expect(wrapper.find('#key-inline-meta').exists()).toBe(true) + expect(wrapper.get('#stat-ttl').text()).toContain('892s') + + // Switch to Value tab (light theme defaults to Protobuf like the prototype). + await wrapper.get('[data-tab="value"]').trigger('click') + await flushPromises() + + expect(wrapper.get('#code-view').text()).toContain('short preview') + + await wrapper.get('#viewer-action-expand').trigger('click') + await flushPromises() + + // Full value is loaded via GET sample_key (mocked by the built-in API mocks). + expect(wrapper.get('#code-view').text()).toContain('value:sample_key') + }) +}) diff --git a/frontend/src/__tests__/redis-console-json-tab.test.ts b/frontend/src/__tests__/redis-console-json-tab.test.ts new file mode 100644 index 0000000..5df2afb --- /dev/null +++ b/frontend/src/__tests__/redis-console-json-tab.test.ts @@ -0,0 +1,110 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Redis console JSON tab', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['sample_key'], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows a notice when the string value is not valid JSON', async () => { + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: 'plain-text-value', + truncated: false, + }, + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const keyRow = wrapper.find('#key-list button') + await keyRow.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="json"]').trigger('click') + await flushPromises() + + expect(wrapper.find('#json-not-json').exists()).toBe(true) + expect(wrapper.get('#json-not-json').text().toLowerCase()).toContain('not a json value') + }) + + it('renders pretty JSON when the string value is valid JSON', async () => { + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '892s' }, + { label: 'Size', value: 128 }, + ], + preview: { + kind: 'string', + limit: 20, + value: '{\"foo\": 1, \"bar\": \"baz\"}', + truncated: false, + }, + }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const keyRow = wrapper.find('#key-list button') + await keyRow.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="json"]').trigger('click') + await flushPromises() + + expect(wrapper.find('#json-not-json').exists()).toBe(false) + expect(wrapper.get('#code-view').text()).toContain('"foo"') + expect(wrapper.get('#code-view').text()).toContain('1') + }) +}) diff --git a/frontend/src/__tests__/redis-console-keys-panel.test.ts b/frontend/src/__tests__/redis-console-keys-panel.test.ts new file mode 100644 index 0000000..791e108 --- /dev/null +++ b/frontend/src/__tests__/redis-console-keys-panel.test.ts @@ -0,0 +1,142 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +const Dummy = { template: '
' } + +describe('Redis console keys panel', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + vi.useFakeTimers() + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + ], + }) + + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('normalizes plain search into a Redis glob pattern', async () => { + const scanSpy = vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('leaderboard') + vi.advanceTimersByTime(300) + await flushPromises() + + const lastCall = scanSpy.mock.calls.at(-1) + expect(lastCall?.[1]).toBe('*leaderboard*') + }) + + it('keeps explicit glob patterns unchanged', async () => { + const scanSpy = vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('user:*') + vi.advanceTimersByTime(300) + await flushPromises() + + const lastCall = scanSpy.mock.calls.at(-1) + expect(lastCall?.[1]).toBe('user:*') + }) + + it('keeps matching key rows visible when using glob patterns', async () => { + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['leaderboard:global'], cursor: '', done: true }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + await wrapper.get('[data-testid="redis-key-search"]').setValue('leaderboard:*') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(wrapper.text()).toContain('leaderboard') + }) + + it('renders search input with enough left padding to avoid icon overlap', async () => { + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + await flushPromises() + + const input = wrapper.get('#key-search') + expect(input.attributes('class')).toContain('pl-10') + expect(input.attributes('class')).toContain('!pl-10') + + const iconContainer = input.element.parentElement?.querySelector('span.absolute') as HTMLElement | null + expect(iconContainer?.className || '').toContain('w-9') + }) +}) diff --git a/frontend/src/__tests__/redis-console-protobuf-tab.test.ts b/frontend/src/__tests__/redis-console-protobuf-tab.test.ts new file mode 100644 index 0000000..f138eee --- /dev/null +++ b/frontend/src/__tests__/redis-console-protobuf-tab.test.ts @@ -0,0 +1,272 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import protobuf from 'protobufjs' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +const editableProto = ` +syntax = "proto3"; + +message UserEvent { + string user_id = 1; +} +` + +const packagedProto = ` +syntax = "proto3"; + +package futrix.issue434; + +message UserEvent { + string user_id = 1; + int32 score = 2; + string action = 3; +} +` + +const emptyProto = ` +syntax = "proto3"; + +message EmptyEvent {} +` + +const buildSchema = (id: string, name: string, content: string) => ({ + id, + datasourceId: 'ds_redis', + name, + content, + createdAt: '2026-05-13T00:00:00Z', + updatedAt: '2026-05-13T00:00:00Z', +}) + +const pickSchemaViaUi = async (wrapper: any, schemaId: string) => { + await wrapper.get('[data-testid="protobuf-schema-picker-trigger"]').trigger('click') + await flushPromises() + await wrapper.get(`[data-testid="protobuf-schema-picker-option-${schemaId}"]`).trigger('click') + await flushPromises() +} + +const pickMessageViaUi = async (wrapper: any, name: string) => { + await wrapper.get('[data-testid="protobuf-message-picker-trigger"]').trigger('click') + await flushPromises() + await wrapper.get(`[data-testid="protobuf-message-picker-option-${name}"]`).trigger('click') + await flushPromises() +} + +describe('Redis protobuf tab', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['protokey'], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '120s' }, + ], + preview: { + kind: 'string', + limit: 20, + rows: [['hello-world']], + value: 'hello-world', + truncated: false, + }, + } as any) + vi.spyOn(api, 'listRedisProtobufSchemas').mockResolvedValue([]) + vi.spyOn(api, 'saveRedisProtobufSchema').mockImplementation(async (payload: any) => + buildSchema(payload.id || `rps_${Date.now()}`, payload.name, payload.content), + ) + vi.spyOn(api, 'deleteRedisProtobufSchema').mockResolvedValue(true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows the not-a-protobuf message when a schema is selected but value does not parse', async () => { + vi.mocked(api.listRedisProtobufSchemas).mockResolvedValue([ + buildSchema('rps_user_event', 'user-event.proto', editableProto), + ]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { global: { plugins: [pinia] } }) + await flushPromises() + + const keyButton = wrapper.findAll('#key-list button').find((button) => button.text().includes('protokey')) + expect(keyButton).toBeTruthy() + await keyButton!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="protobuf"]').trigger('click') + await flushPromises() + + await pickSchemaViaUi(wrapper, 'rps_user_event') + await pickMessageViaUi(wrapper, 'UserEvent') + + expect(wrapper.get('#protobuf-not-protobuf').text()).toContain('Not a Protobuf value.') + }) + + it('decodes unpadded base64 protobuf from the Redis string preview value', async () => { + const root = protobuf.parse(packagedProto, { keepCase: true }).root + const type = root.lookupType('futrix.issue434.UserEvent') + const encoded = type.encode(type.create({ user_id: 'issue-434', score: 434, action: 'redis-protobuf' })).finish() + const unpaddedBase64 = Buffer.from(encoded).toString('base64').replace(/=+$/, '') + + vi.mocked(api.describeEntity).mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '120s' }, + ], + preview: { + kind: 'string', + limit: 20, + value: unpaddedBase64, + truncated: false, + }, + } as any) + + vi.mocked(api.listRedisProtobufSchemas).mockResolvedValue([ + buildSchema('rps_packaged', 'packaged.proto', packagedProto), + ]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { global: { plugins: [pinia] } }) + await flushPromises() + + const keyButton = wrapper.findAll('#key-list button').find((button) => button.text().includes('protokey')) + expect(keyButton).toBeTruthy() + await keyButton!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="protobuf"]').trigger('click') + await flushPromises() + + // Auto-detect should pick the schema and message; fall back to manual pick if not. + if (wrapper.find('[data-testid="protobuf-message-picker-trigger"]').exists()) { + await pickSchemaViaUi(wrapper, 'rps_packaged').catch(() => {}) + await pickMessageViaUi(wrapper, 'futrix.issue434.UserEvent').catch(() => {}) + } + + expect(wrapper.find('#protobuf-not-protobuf').exists()).toBe(false) + expect(wrapper.text()).toContain('"user_id": "issue-434"') + expect(wrapper.text()).toContain('"score": 434') + expect(wrapper.text()).toContain('"action": "redis-protobuf"') + }) + + it('decodes protobuf wire text without trimming leading wire bytes', async () => { + const root = protobuf.parse(editableProto, { keepCase: true }).root + const type = root.lookupType('UserEvent') + const encoded = type.encode(type.create({ user_id: 'u_1' })).finish() + const wireText = new TextDecoder().decode(encoded) + + vi.mocked(api.describeEntity).mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '120s' }, + ], + preview: { + kind: 'string', + limit: 20, + rows: [[wireText]], + value: wireText, + truncated: false, + }, + } as any) + + vi.mocked(api.listRedisProtobufSchemas).mockResolvedValue([ + buildSchema('rps_user_event', 'user-event.proto', editableProto), + ]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { global: { plugins: [pinia] } }) + await flushPromises() + + const keyButton = wrapper.findAll('#key-list button').find((button) => button.text().includes('protokey')) + expect(keyButton).toBeTruthy() + await keyButton!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="protobuf"]').trigger('click') + await flushPromises() + + if (wrapper.find('[data-testid="protobuf-message-picker-trigger"]').exists()) { + await pickSchemaViaUi(wrapper, 'rps_user_event').catch(() => {}) + await pickMessageViaUi(wrapper, 'UserEvent').catch(() => {}) + } + + expect(wrapper.find('#protobuf-not-protobuf').exists()).toBe(false) + expect(wrapper.text()).toContain('"user_id": "u_1"') + }) + + it('treats empty string payload as valid empty protobuf message', async () => { + vi.mocked(api.describeEntity).mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '120s' }, + ], + preview: { + kind: 'string', + limit: 20, + rows: [['']], + value: '', + truncated: false, + }, + } as any) + + vi.mocked(api.listRedisProtobufSchemas).mockResolvedValue([ + buildSchema('rps_empty', 'empty.proto', emptyProto), + ]) + + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + + const wrapper = mount(ConsoleView, { global: { plugins: [pinia] } }) + await flushPromises() + + const keyButton = wrapper.findAll('#key-list button').find((button) => button.text().includes('protokey')) + expect(keyButton).toBeTruthy() + await keyButton!.trigger('click') + await flushPromises() + + await wrapper.get('[data-tab="protobuf"]').trigger('click') + await flushPromises() + + await pickSchemaViaUi(wrapper, 'rps_empty') + await pickMessageViaUi(wrapper, 'EmptyEvent') + + expect(wrapper.find('#protobuf-not-protobuf').exists()).toBe(false) + expect(wrapper.text()).toContain('{}') + }) +}) diff --git a/frontend/src/__tests__/redis-console-prototype-shell.test.ts b/frontend/src/__tests__/redis-console-prototype-shell.test.ts new file mode 100644 index 0000000..ea8fe39 --- /dev/null +++ b/frontend/src/__tests__/redis-console-prototype-shell.test.ts @@ -0,0 +1,54 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Redis console prototype shell', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the prototype-aligned redis shell', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status.ds_redis = 'connected' + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="redis-proto-shell"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="redis-session-tabs"]').classes()).toContain('statement-tabs') + expect(wrapper.get('[data-testid="redis-session-shell-main"]').find('[data-testid="redis-session-tabs"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="statement-tab"]').classes()).toContain('statement-tab--sql-editor') + expect(wrapper.get('[data-testid="statement-tab-add"]').classes()).toContain('statement-tab-add--sql-editor') + expect(wrapper.find('[data-testid="redis-inspector-tab-preview"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="redis-proto-keys"] button[aria-label=\"Settings\"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="redis-proto-keys"] button[aria-label=\"Scroll keys left\"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="redis-proto-keys"] button[aria-label=\"Scroll keys right\"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/redis-console-resource-metrics.test.ts b/frontend/src/__tests__/redis-console-resource-metrics.test.ts new file mode 100644 index 0000000..3348c2f --- /dev/null +++ b/frontend/src/__tests__/redis-console-resource-metrics.test.ts @@ -0,0 +1,350 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'console', params: { id: 'ds_redis_cluster' } }), + useRouter: () => ({ push: vi.fn() }), +})) + +describe('Redis console resource metrics', () => { + let pinia: ReturnType + let scanRedisKeysSpy: any + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + scanRedisKeysSpy = vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: [], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows node selector for redis cluster and reloads metrics for selected node', async () => { + const nodes = ['10.0.0.1:7000', '10.0.0.2:7001'] + const metricsSpy = vi.spyOn(api, 'getDatasourceMetrics').mockImplementation(async (_id: string, node = '') => { + const selected = node && nodes.includes(node) ? node : nodes[0] + if (selected === nodes[1]) { + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: selected, + nodes, + cpuAvailable: true, + cpuPercent: 73.2, + memoryAvailable: true, + memoryUsedBytes: 68 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '68.0 MB', + memoryTotalText: '128 MB', + } as any + } + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: selected, + nodes, + cpuAvailable: true, + cpuPercent: 21.5, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } as any + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis Cluster', + type: 'redis', + host: '10.0.0.1', + port: 7000, + options: { nodes }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.get('[data-testid="redis-resource-strip"]').exists()).toBe(true) + const selector = wrapper.get('[data-testid="redis-metrics-node-select"]') + expect(selector.exists()).toBe(true) + expect(metricsSpy.mock.calls.some((call) => call[1] === '')).toBe(true) + const metricsCallsBeforeChange = metricsSpy.mock.calls.length + const keyScanCallsBeforeChange = scanRedisKeysSpy.mock.calls.length + + await selector.setValue(nodes[1]) + await flushPromises() + + expect(metricsSpy.mock.calls.length).toBe(metricsCallsBeforeChange + 1) + expect(metricsSpy.mock.calls.some((call) => call[1] === nodes[1])).toBe(true) + expect(scanRedisKeysSpy.mock.calls.length).toBe(keyScanCallsBeforeChange) + expect(wrapper.text()).toContain('73.2%') + expect(wrapper.text()).toContain('68.0 MB') + }) + + it('keeps backend fallback polling when metrics node is empty', async () => { + vi.useFakeTimers() + const nodes = ['10.0.0.1:7000', '10.0.0.2:7001'] + const metricsSpy = vi.spyOn(api, 'getDatasourceMetrics').mockImplementation(async () => { + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: '', + nodes, + cpuAvailable: true, + cpuPercent: 18.5, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } as any + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis Cluster', + type: 'redis', + host: '10.0.0.1', + port: 7000, + options: { nodes }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + expect(metricsSpy.mock.calls.length).toBeGreaterThan(0) + expect(metricsSpy.mock.calls[0]?.[1]).toBe('') + + vi.advanceTimersByTime(10_000) + await flushPromises() + + expect(metricsSpy.mock.calls.length).toBeGreaterThan(1) + expect(metricsSpy.mock.calls[1]?.[1]).toBe('') + wrapper.unmount() + vi.useRealTimers() + }) + + it('does not auto-pin metrics polling when backend returns a node without user selection', async () => { + vi.useFakeTimers() + const nodes = ['10.0.0.1:7000', '10.0.0.2:7001'] + const metricsSpy = vi.spyOn(api, 'getDatasourceMetrics').mockImplementation(async (_id: string, node = '') => { + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: node || nodes[0], + nodes, + cpuAvailable: true, + cpuPercent: 18.5, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } as any + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis Cluster', + type: 'redis', + host: '10.0.0.1', + port: 7000, + options: { nodes }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + expect(metricsSpy.mock.calls.length).toBeGreaterThan(0) + expect(metricsSpy.mock.calls[0]?.[1]).toBe('') + + vi.advanceTimersByTime(10_000) + await flushPromises() + + expect(metricsSpy.mock.calls.length).toBeGreaterThan(1) + expect(metricsSpy.mock.calls[1]?.[1]).toBe('') + + wrapper.unmount() + vi.useRealTimers() + }) + + it('keeps user-pinned node polling when backend response has node but no nodes list', async () => { + vi.useFakeTimers() + const nodes = ['10.0.0.1:7000', '10.0.0.2:7001'] + const metricsSpy = vi.spyOn(api, 'getDatasourceMetrics').mockImplementation(async (_id: string, node = '') => { + if (node === nodes[1]) { + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: nodes[1], + nodes: [], + cpuAvailable: true, + cpuPercent: 73.2, + memoryAvailable: true, + memoryUsedBytes: 68 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '68.0 MB', + memoryTotalText: '128 MB', + } as any + } + return { + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + node: nodes[0], + nodes, + cpuAvailable: true, + cpuPercent: 21.5, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } as any + }) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis Cluster', + type: 'redis', + host: '10.0.0.1', + port: 7000, + options: { nodes }, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + const selector = wrapper.get('[data-testid="redis-metrics-node-select"]') + await selector.setValue(nodes[1]) + await flushPromises() + + expect(metricsSpy.mock.calls.some((call) => call[1] === nodes[1])).toBe(true) + + vi.advanceTimersByTime(10_000) + await flushPromises() + + expect(metricsSpy.mock.calls.length).toBeGreaterThan(2) + expect(metricsSpy.mock.calls[2]?.[1]).toBe(nodes[1]) + + wrapper.unmount() + vi.useRealTimers() + }) + + it('hides node selector for standalone redis datasource', async () => { + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue({ + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + cpuAvailable: true, + cpuPercent: 12.8, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="redis-metrics-node-select"]').exists()).toBe(false) + }) + + it('caps cpu and memory gauge arcs at 99.5 when usage reaches 100%', async () => { + vi.spyOn(api, 'getDatasourceMetrics').mockResolvedValue({ + datasourceId: 'ds_redis_cluster', + datasourceType: 'redis', + collectedAt: Date.now(), + cpuAvailable: true, + cpuPercent: 100, + memoryAvailable: true, + memoryUsedBytes: 64 * 1024 * 1024, + memoryTotalBytes: 64 * 1024 * 1024, + memoryUsedText: '64.0 MB', + memoryTotalText: '64 MB', + } as any) + + const store = useAppStore() + store.datasources = [ + { + id: 'ds_redis_cluster', + name: 'Redis', + type: 'redis', + host: '127.0.0.1', + port: 6379, + } as any, + ] + + const wrapper = mount(ConsoleView, { + global: { + plugins: [pinia], + }, + }) + + await flushPromises() + + const gaugeArcs = wrapper.findAll('[data-testid="redis-resource-strip"] svg path[stroke-linecap="round"]') + expect(gaugeArcs).toHaveLength(2) + expect(gaugeArcs[0]?.attributes('stroke-dasharray')).toBe('99.5, 100') + expect(gaugeArcs[1]?.attributes('stroke-dasharray')).toBe('99.5, 100') + }) +}) diff --git a/frontend/src/__tests__/redis-console-single-scroll.test.ts b/frontend/src/__tests__/redis-console-single-scroll.test.ts new file mode 100644 index 0000000..99300a6 --- /dev/null +++ b/frontend/src/__tests__/redis-console-single-scroll.test.ts @@ -0,0 +1,80 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import ConsoleView from '@/views/ConsoleView.vue' +import { useAppStore } from '@/stores/app' +import { api } from '@/services/api' + +const Dummy = { template: '
' } + +describe('Redis console — single scroll surface (TASK-20260513-160154)', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + ], + }) + await router.push({ name: 'console', params: { id: 'ds_redis' } }) + await router.isReady() + + vi.spyOn(api, 'listHistory').mockResolvedValue([]) + vi.spyOn(api, 'scanRedisKeys').mockResolvedValue({ keys: ['k1'], cursor: '', done: true }) + vi.spyOn(api, 'getRedisCommandDocs').mockResolvedValue({ updatedAt: 0, commands: {} }) + vi.spyOn(api, 'describeEntity').mockResolvedValue({ + columns: [], + indexes: [], + details: [ + { label: 'Type', value: 'string' }, + { label: 'TTL', value: '-' }, + { label: 'Size', value: 12 }, + ], + preview: { kind: 'string', limit: 20, value: 'v', truncated: false }, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('right pane has no overflow on its outer container; the code panel is the only scroll surface', async () => { + const store = useAppStore() + store.datasources = [ + { id: 'ds_redis', name: 'Redis', type: 'redis', host: '127.0.0.1', port: 6379 } as any, + ] + store.status['ds_redis'] = 'connected' + + const wrapper = mount(ConsoleView, { global: { plugins: [pinia, router] } }) + await flushPromises() + await flushPromises() + + const header = wrapper.find('#key-inspector-header') + expect(header.exists()).toBe(true) + expect(header.classes()).toContain('shrink-0') + + const viewerCard = wrapper.get('#viewer-card') + expect(viewerCard.classes()).toContain('flex-1') + expect(viewerCard.classes()).toContain('min-h-0') + + const html = wrapper.html() + const overflowAutoCount = (html.match(/overflow-(auto|y-auto)/g) || []).length + expect(overflowAutoCount).toBeGreaterThan(0) + + const outer = header.element.parentElement as HTMLElement | null + expect(outer).toBeTruthy() + if (outer) { + const cls = outer.className || '' + expect(cls).not.toMatch(/\boverflow-y-auto\b/) + expect(cls).not.toMatch(/\boverflow-auto\b/) + expect(cls).toMatch(/flex-col/) + } + }) +}) diff --git a/frontend/src/__tests__/redis-full-view.test.ts b/frontend/src/__tests__/redis-full-view.test.ts new file mode 100644 index 0000000..d6047d9 --- /dev/null +++ b/frontend/src/__tests__/redis-full-view.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest' +import { buildRedisFullView } from '../modules/redis/common' + +describe('buildRedisFullView', () => { + it('returns isEmpty for null', () => { + const v = buildRedisFullView(null, 'hash') + expect(v.isEmpty).toBe(true) + expect(v.rows).toEqual([]) + expect(v.kind).toBe('hash') + }) + + it('returns isEmpty for empty object', () => { + const v = buildRedisFullView({}, 'hash') + expect(v.isEmpty).toBe(true) + }) + + it('returns isEmpty for empty array', () => { + const v = buildRedisFullView([], 'list') + expect(v.isEmpty).toBe(true) + }) + + it('never returns literal {} for empty hash', () => { + const v = buildRedisFullView({}, 'hash') + expect(v.rows).not.toContainEqual(['{}']) + }) + + it('renders string raw value as single-row table', () => { + const v = buildRedisFullView('hello world', 'string') + expect(v.isEmpty).toBe(false) + expect(v.headers).toEqual(['Value']) + expect(v.rows).toEqual([['hello world']]) + }) + + it('renders hash from {field: value} object', () => { + const v = buildRedisFullView({ a: '1', b: '2' }, 'hash') + expect(v.isEmpty).toBe(false) + expect(v.headers).toEqual(['Field', 'Value']) + expect(v.rows).toContainEqual(['a', '1']) + expect(v.rows).toContainEqual(['b', '2']) + }) + + it('renders hash from flat [field, value, ...] array', () => { + const v = buildRedisFullView(['k1', 'v1', 'k2', 'v2'], 'hash') + expect(v.rows).toEqual([ + ['k1', 'v1'], + ['k2', 'v2'], + ]) + }) + + it('renders list with indexes', () => { + const v = buildRedisFullView(['a', 'b', 'c'], 'list') + expect(v.headers).toEqual(['Index', 'Value']) + expect(v.rows).toEqual([ + ['0', 'a'], + ['1', 'b'], + ['2', 'c'], + ]) + }) + + it('renders set members', () => { + const v = buildRedisFullView(['x', 'y'], 'set') + expect(v.headers).toEqual(['Member']) + expect(v.rows).toEqual([['x'], ['y']]) + }) + + it('renders zset from flat array', () => { + const v = buildRedisFullView(['m1', 1.5, 'm2', 2.5], 'zset') + expect(v.headers).toEqual(['Member', 'Score']) + expect(v.rows).toEqual([ + ['m1', '1.5'], + ['m2', '2.5'], + ]) + }) + + it('renders zset from {member: score} object', () => { + const v = buildRedisFullView({ a: 1, b: 2 }, 'zset') + expect(v.rows).toContainEqual(['a', '1']) + expect(v.rows).toContainEqual(['b', '2']) + }) + + it('renders zset from [{member, score}] array', () => { + const v = buildRedisFullView([{ member: 'a', score: 1 }], 'zset') + expect(v.rows).toEqual([['a', '1']]) + }) + + it('renders stream entries (object shape from preview path)', () => { + const v = buildRedisFullView( + [{ id: '1-0', fields: { foo: 'bar' } }], + 'stream', + ) + expect(v.headers).toEqual(['ID', 'Fields']) + expect(v.rows[0][0]).toBe('1-0') + expect(v.rows[0][1]).toContain('foo') + }) + + it('renders stream entries (RESP wire shape from full-fetch path)', () => { + // client.Do("XRANGE", ...) returns [[id, [field, value, ...]], ...] + const v = buildRedisFullView( + [ + ['1700000000-0', ['foo', 'bar', 'baz', '1']], + ['1700000001-0', ['x', 'y']], + ], + 'stream', + ) + expect(v.headers).toEqual(['ID', 'Fields']) + expect(v.rows[0][0]).toBe('1700000000-0') + expect(v.rows[0][1]).toContain('foo') + expect(v.rows[0][1]).toContain('bar') + expect(v.rows[0][1]).toContain('baz') + expect(v.rows[1][0]).toBe('1700000001-0') + expect(v.rows[1][1]).toContain('x') + expect(v.rows[1][1]).not.toBe('-') + }) + + it('falls back to single-cell rendering for unknown kind', () => { + const v = buildRedisFullView('payload', 'unknown') + expect(v.headers).toEqual(['Value']) + expect(v.rows).toEqual([['payload']]) + }) + + it('returns isEmpty for empty string', () => { + const v = buildRedisFullView('', 'string') + expect(v.isEmpty).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/redis-protobuf-manage-dialog.test.ts b/frontend/src/__tests__/redis-protobuf-manage-dialog.test.ts new file mode 100644 index 0000000..ff4b778 --- /dev/null +++ b/frontend/src/__tests__/redis-protobuf-manage-dialog.test.ts @@ -0,0 +1,108 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import ProtobufManageDialog from '@/components/redis-protobuf/ProtobufManageDialog.vue' +import { useRedisProtobufStore } from '@/stores/redis-protobuf' +import { api } from '@/services/api' + +const buildSchema = (overrides: Partial<{ id: string; datasourceId: string; name: string; content: string }> = {}) => ({ + id: 'rps_global', + datasourceId: '', + name: 'global.proto', + content: 'syntax = "proto3"; message G { string id = 1; }', + createdAt: '2026-05-13T00:00:00Z', + updatedAt: '2026-05-13T00:00:00Z', + ...overrides, +}) + +describe('ProtobufManageDialog scope preservation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps datasourceId empty when editing a global schema from a datasource context', async () => { + const globalSchema = buildSchema() + vi.spyOn(api, 'listRedisProtobufSchemas').mockResolvedValue([globalSchema]) + const saveSpy = vi.spyOn(api, 'saveRedisProtobufSchema').mockImplementation(async (payload: any) => ({ + ...globalSchema, + ...payload, + datasourceId: payload.datasourceId, + })) + + const store = useRedisProtobufStore() + await store.ensureLoaded('ds_redis') + + const wrapper = mount(ProtobufManageDialog, { + props: { open: true, datasourceId: 'ds_redis' }, + }) + await flushPromises() + + await wrapper.get(`[data-testid="protobuf-manage-item-${globalSchema.id}"]`).trigger('click') + await wrapper.get('[data-testid="protobuf-manage-name"]').setValue('renamed.proto') + await wrapper.get('[data-testid="protobuf-manage-save"]').trigger('click') + await flushPromises() + + expect(saveSpy).toHaveBeenCalledTimes(1) + const payload = saveSpy.mock.calls[0][0] as { datasourceId: string } + expect(payload.datasourceId).toBe('') + }) + + it('uses props.datasourceId when creating a new schema', async () => { + vi.spyOn(api, 'listRedisProtobufSchemas').mockResolvedValue([]) + const saveSpy = vi.spyOn(api, 'saveRedisProtobufSchema').mockImplementation(async (payload: any) => ({ + ...buildSchema({ id: 'rps_new' }), + ...payload, + })) + + const store = useRedisProtobufStore() + await store.ensureLoaded('ds_redis') + + const wrapper = mount(ProtobufManageDialog, { + props: { open: true, datasourceId: 'ds_redis' }, + }) + await flushPromises() + + await wrapper.get('[data-testid="protobuf-manage-add"]').trigger('click') + await wrapper.get('[data-testid="protobuf-manage-name"]').setValue('new.proto') + await wrapper.get('[data-testid="protobuf-manage-content"]').setValue('syntax = "proto3"; message N { string id = 1; }') + await wrapper.get('[data-testid="protobuf-manage-save"]').trigger('click') + await flushPromises() + + expect(saveSpy).toHaveBeenCalledTimes(1) + const payload = saveSpy.mock.calls[0][0] as { datasourceId: string; id?: string } + expect(payload.datasourceId).toBe('ds_redis') + expect(payload.id).toBeUndefined() + }) + + it('deletes using the schema\'s own datasourceId and survives selection being cleared after remove', async () => { + const globalSchema = buildSchema() + // After remove, the refreshed list no longer contains the schema. + let removed = false + vi.spyOn(api, 'listRedisProtobufSchemas').mockImplementation(async () => (removed ? [] : [globalSchema])) + const deleteSpy = vi.spyOn(api, 'deleteRedisProtobufSchema').mockImplementation(async () => { + removed = true + }) + vi.spyOn(window, 'confirm').mockReturnValue(true) + + const store = useRedisProtobufStore() + await store.ensureLoaded('ds_redis') + + const wrapper = mount(ProtobufManageDialog, { + props: { open: true, datasourceId: 'ds_redis' }, + }) + await flushPromises() + + await wrapper.get(`[data-testid="protobuf-manage-item-${globalSchema.id}"]`).trigger('click') + await wrapper.get('[data-testid="protobuf-manage-delete"]').trigger('click') + await flushPromises() + + expect(deleteSpy).toHaveBeenCalledWith(globalSchema.id) + expect(wrapper.emitted('deleted')?.[0]).toEqual([globalSchema.id]) + expect(wrapper.emitted('error')).toBeUndefined() + }) +}) diff --git a/frontend/src/__tests__/redis-protobuf-store-cache.test.ts b/frontend/src/__tests__/redis-protobuf-store-cache.test.ts new file mode 100644 index 0000000..7929149 --- /dev/null +++ b/frontend/src/__tests__/redis-protobuf-store-cache.test.ts @@ -0,0 +1,122 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useRedisProtobufStore } from '@/stores/redis-protobuf' +import { api } from '@/services/api' + +const buildSchema = (overrides: Partial<{ id: string; datasourceId: string; name: string; content: string }> = {}) => ({ + id: overrides.id ?? 'rps_global', + datasourceId: overrides.datasourceId ?? '', + name: overrides.name ?? 'global.proto', + content: overrides.content ?? 'syntax = "proto3"; message G { string id = 1; }', + createdAt: '2026-05-13T00:00:00Z', + updatedAt: '2026-05-13T00:00:00Z', +}) + +describe('redis-protobuf store cache invalidation', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('refreshes every cached datasource bucket when a global schema is saved', async () => { + const initialGlobal = buildSchema({ id: 'rps_g1', name: 'old.proto' }) + const scopedSchema = buildSchema({ id: 'rps_s1', datasourceId: 'ds_a', name: 'scoped.proto' }) + + let saved = false + const listSpy = vi.spyOn(api, 'listRedisProtobufSchemas').mockImplementation(async (datasourceId?: string) => { + const updatedGlobal = saved ? { ...initialGlobal, name: 'new.proto' } : initialGlobal + if (!datasourceId) return [updatedGlobal] + // Backend surfaces scoped + global for non-empty datasourceId. + return [scopedSchema, updatedGlobal] + }) + vi.spyOn(api, 'saveRedisProtobufSchema').mockImplementation(async () => { + saved = true + return { ...initialGlobal, name: 'new.proto' } + }) + + const store = useRedisProtobufStore() + + await store.ensureLoaded('') + await store.ensureLoaded('ds_a') + await store.ensureLoaded('ds_b') + expect(store.schemasFor('ds_a').find((s) => s.id === 'rps_g1')?.name).toBe('old.proto') + expect(store.schemasFor('ds_b').find((s) => s.id === 'rps_g1')?.name).toBe('old.proto') + + listSpy.mockClear() + await store.save({ id: 'rps_g1', datasourceId: '', name: 'new.proto', content: 'syntax = "proto3"; message G { string id = 1; }' }) + + // Every previously-cached bucket should be refetched so stale global schema names are evicted. + const refreshed = new Set(listSpy.mock.calls.map((call) => call[0] ?? '')) + expect(refreshed.has('')).toBe(true) + expect(refreshed.has('ds_a')).toBe(true) + expect(refreshed.has('ds_b')).toBe(true) + + expect(store.schemasFor('ds_a').find((s) => s.id === 'rps_g1')?.name).toBe('new.proto') + expect(store.schemasFor('ds_b').find((s) => s.id === 'rps_g1')?.name).toBe('new.proto') + }) + + it('refreshes affected scoped bucket and the "list-everything" bucket when a scoped schema is saved', async () => { + const scopedSchema = buildSchema({ id: 'rps_s1', datasourceId: 'ds_a', name: 'a.proto' }) + const otherScoped = buildSchema({ id: 'rps_s2', datasourceId: 'ds_b', name: 'b.proto' }) + + const listSpy = vi.spyOn(api, 'listRedisProtobufSchemas').mockImplementation(async (datasourceId?: string) => { + // Backend: empty selector returns ALL schemas (full catalogue), not just globals. + if (!datasourceId) return [scopedSchema, otherScoped] + if (datasourceId === 'ds_a') return [scopedSchema] + if (datasourceId === 'ds_b') return [otherScoped] + return [] + }) + vi.spyOn(api, 'saveRedisProtobufSchema').mockImplementation(async (payload: any) => ({ + ...scopedSchema, + ...payload, + })) + + const store = useRedisProtobufStore() + await store.ensureLoaded('') + await store.ensureLoaded('ds_a') + await store.ensureLoaded('ds_b') + + listSpy.mockClear() + await store.save({ id: 'rps_s1', datasourceId: 'ds_a', name: 'a-renamed.proto', content: scopedSchema.content }) + + const refreshed = new Set(listSpy.mock.calls.map((call) => call[0] ?? '')) + expect(refreshed.has('ds_a')).toBe(true) + // The '' bucket is the full-catalogue cache; it must be invalidated too. + expect(refreshed.has('')).toBe(true) + // Other scoped buckets don't surface this scoped schema, so they stay cached. + expect(refreshed.has('ds_b')).toBe(false) + }) + + it('does not let a slow earlier load clobber a fresher force-refresh', async () => { + const oldList = [buildSchema({ id: 'rps_old', name: 'old.proto' })] + const freshList = [buildSchema({ id: 'rps_old', name: 'fresh.proto' })] + + let resolveOld: ((value: typeof oldList) => void) | null = null + let call = 0 + vi.spyOn(api, 'listRedisProtobufSchemas').mockImplementation(() => { + call += 1 + if (call === 1) { + return new Promise((resolve) => { + resolveOld = resolve + }) + } + return Promise.resolve(freshList) + }) + + const store = useRedisProtobufStore() + // Kick off a slow initial load (resolution deferred until we resolve it manually). + const firstPromise = store.ensureLoaded('ds_a') + // Force refresh while the first call is still in flight; this must win. + await store.ensureLoaded('ds_a', true) + expect(store.schemasFor('ds_a').map((s) => s.name)).toEqual(['fresh.proto']) + + // Now let the stale earlier call resolve last; it must not clobber the cache. + resolveOld!(oldList) + await firstPromise.catch(() => {}) + expect(store.schemasFor('ds_a').map((s) => s.name)).toEqual(['fresh.proto']) + }) +}) diff --git a/frontend/src/__tests__/redis-tree.test.ts b/frontend/src/__tests__/redis-tree.test.ts new file mode 100644 index 0000000..a785d0a --- /dev/null +++ b/frontend/src/__tests__/redis-tree.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { buildTree } from '../modules/redis/tree' + +describe('redis tree', () => { + it('builds folder nodes for shared prefixes', () => { + const keys = ['codex:alpha:1', 'codex:beta:2'] + const items = buildTree(keys, ':', 6, new Set()) + const codex = items.find((item) => item.id === 'codex') + expect(codex?.isFolder).toBe(true) + expect(codex?.childrenCount).toBe(2) + }) + + it('expands lazily when a prefix is opened', () => { + const keys = ['codex:alpha:1', 'codex:beta:2'] + const collapsed = buildTree(keys, ':', 6, new Set()) + expect(collapsed.some((item) => item.id === 'codex:alpha')).toBe(false) + + const expanded = buildTree(keys, ':', 6, new Set(['codex'])) + expect(expanded.some((item) => item.id === 'codex:alpha')).toBe(true) + expect(expanded.some((item) => item.id === 'codex:beta')).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/risk-rules-css.test.ts b/frontend/src/__tests__/risk-rules-css.test.ts new file mode 100644 index 0000000..fd4cdc6 --- /dev/null +++ b/frontend/src/__tests__/risk-rules-css.test.ts @@ -0,0 +1,29 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const css = readCssWithImports(path.resolve(__dirname, '..', 'style.css')) + +describe('risk rules css', () => { + it('keeps risk rule form action controls at a usable tap target size', () => { + const riskChip = css.match(/\.risk-chip\s*\{[\s\S]*?\}/)?.[0] ?? '' + const thresholdsToggle = css.match(/\.risk-thresholds-toggle\s*\{[\s\S]*?\}/)?.[0] ?? '' + const entityBrowse = css.match(/\.risk-entity-browse-link\s*\{[\s\S]*?\}/)?.[0] ?? '' + const typeTab = css.match(/\.risk-type-tab\s*\{[\s\S]*?\}/)?.[0] ?? '' + const listActions = css.match(/\.risk-rule-actions\s+\.btn\.mini\s*\{[\s\S]*?\}/)?.[0] ?? '' + const importExport = css.match(/\.risk-import-export\s+\.btn\.small\s*\{[\s\S]*?\}/)?.[0] ?? '' + const infoTop = css.match(/\.risk-rule-info-top\s*\{[\s\S]*?\}/)?.[0] ?? '' + const ruleName = css.match(/\.risk-rule-name\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(riskChip).toMatch(/min-height:\s*32px/i) + expect(thresholdsToggle).toMatch(/min-height:\s*32px/i) + expect(entityBrowse).toMatch(/min-height:\s*32px/i) + expect(typeTab).toMatch(/min-height:\s*32px/i) + expect(listActions).toMatch(/min-height:\s*32px/i) + expect(importExport).toMatch(/min-height:\s*32px/i) + expect(infoTop).toMatch(/flex-wrap:\s*wrap/i) + expect(ruleName).toMatch(/white-space:\s*normal/i) + }) +}) diff --git a/frontend/src/__tests__/risk-rules-plan-gating.test.ts b/frontend/src/__tests__/risk-rules-plan-gating.test.ts new file mode 100644 index 0000000..ebabf23 --- /dev/null +++ b/frontend/src/__tests__/risk-rules-plan-gating.test.ts @@ -0,0 +1,702 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const pushMock = vi.fn() +const listRulesMock = vi.fn() +const listUserRulesMock = vi.fn() +const addRuleMock = vi.fn() +const updateRuleMock = vi.fn() +const deleteRuleMock = vi.fn() +const setEnabledMock = vi.fn() +const setBuiltinEnabledMock = vi.fn() +const updateBuiltinProbeRuleThresholdsMock = vi.fn() +const listEntitiesMock = vi.fn() + +const routeState: { name: string; params: Record; query: Record } = { + name: 'risk-rules', + params: {}, + query: {}, +} + +vi.mock('vue-router', () => ({ + useRoute: () => routeState, + useRouter: () => ({ push: pushMock }), +})) + +vi.mock('@wailsjs/go/main/App', () => ({ + RiskEngineListRules: (...args: any[]) => listRulesMock(...args), + RiskEngineListUserRules: (...args: any[]) => listUserRulesMock(...args), + RiskEngineAddRule: (...args: any[]) => addRuleMock(...args), + RiskEngineUpdateRule: (...args: any[]) => updateRuleMock(...args), + RiskEngineDeleteRule: (...args: any[]) => deleteRuleMock(...args), + RiskEngineSetEnabled: (...args: any[]) => setEnabledMock(...args), + RiskEngineSetBuiltinEnabled: (...args: any[]) => setBuiltinEnabledMock(...args), + RiskEngineUpdateBuiltinProbeRuleThresholds: (...args: any[]) => updateBuiltinProbeRuleThresholdsMock(...args), + ListEntities: (...args: any[]) => listEntitiesMock(...args), +})) + +import RiskRulesFormView from '@/views/RiskRulesFormView.vue' +import RiskRulesView from '@/views/RiskRulesView.vue' +import { useAppStore } from '@/stores/app' +import { useAuthStore } from '@/stores/auth' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('risk rules plan gating', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + resetAppI18nForTest() + setAppLocale('en') + pushMock.mockReset() + listRulesMock.mockReset() + listUserRulesMock.mockReset() + addRuleMock.mockReset() + updateRuleMock.mockReset() + deleteRuleMock.mockReset() + setEnabledMock.mockReset() + setBuiltinEnabledMock.mockReset() + updateBuiltinProbeRuleThresholdsMock.mockReset() + listEntitiesMock.mockReset() + routeState.name = 'risk-rules' + routeState.params = {} + routeState.query = {} + + listRulesMock.mockResolvedValue([ + { id: 'builtin-1', code: 'RR-001', builtin: true, enabled: true, action: 'warn', reason: 'builtin', scope: { dsTypes: ['mysql'] } }, + ]) + listUserRulesMock.mockResolvedValue([ + { id: 'user-rule-1', code: 'CR-001', builtin: false, enabled: true, description: 'User rule', action: 'warn', reason: 'custom', scope: { dsTypes: ['mysql'] } }, + ]) + listEntitiesMock.mockResolvedValue([]) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('allows signed-in free users to create, edit, delete, import, and export custom rules', async () => { + const store = useAppStore() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + listRulesMock.mockResolvedValue([ + { id: 'builtin-1', code: 'RR-001', builtin: true, enabled: true, action: 'warn', reason: 'builtin', scope: { dsTypes: ['mysql'] } }, + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 1000 }, + }, + ]) + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const exportCallsBefore = listUserRulesMock.mock.calls.length + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules-create' }) + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('common.edit'))!.trigger('click') + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules-edit', params: { id: 'user-rule-1' }, query: { kind: 'custom' } }) + + await wrapper.find('[data-rule-id="user-rule-1"]').findAll('button').find((btn) => btn.text() === tApp('common.delete'))!.trigger('click') + await flushPromises() + expect(confirmSpy).toHaveBeenCalledTimes(1) + expect(deleteRuleMock).toHaveBeenCalledWith('user-rule-1') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.import'))!.trigger('click') + expect(wrapper.text()).toContain(tApp('riskRules.importTitle')) + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.export'))!.trigger('click') + expect(listUserRulesMock.mock.calls.length).toBeGreaterThan(exportCallsBefore) + expect(store.notice.message).not.toBe(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) + + it('keeps builtin risk-rule editing pro-gated for signed-in free users', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + listRulesMock.mockResolvedValue([ + { id: 'builtin-1', code: 'RR-001', builtin: true, enabled: true, action: 'warn', reason: 'builtin', scope: { dsTypes: ['mysql'] } }, + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 1000 }, + }, + ]) + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('[data-rule-id="probe-wide-scan"] button.btn').trigger('click') + + expect(pushMock).not.toHaveBeenCalledWith({ name: 'risk-rules-edit', params: { id: 'probe-wide-scan' }, query: { kind: 'builtin' } }) + expect(store.notice.message).toBe(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) + + it('blocks logged-out users from create, edit, delete, import, and export actions', async () => { + const store = useAppStore() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_guest', + session: null, + pendingLogin: null, + } as any + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const exportCallsBefore = listUserRulesMock.mock.calls.length + const buttons = () => wrapper.findAll('button') + + await buttons().find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + expect(pushMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + + await buttons().find((btn) => btn.text() === tApp('common.edit'))!.trigger('click') + expect(pushMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + + await wrapper.find('[data-rule-id="user-rule-1"]').findAll('button').find((btn) => btn.text() === tApp('common.delete'))!.trigger('click') + await flushPromises() + expect(confirmSpy).not.toHaveBeenCalled() + expect(deleteRuleMock).not.toHaveBeenCalled() + expect(store.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + + await buttons().find((btn) => btn.text() === tApp('riskRules.import'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).not.toContain(tApp('riskRules.importTitle')) + expect(store.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + + await buttons().find((btn) => btn.text() === tApp('riskRules.export'))!.trigger('click') + await flushPromises() + expect(listUserRulesMock.mock.calls.length).toBe(exportCallsBefore) + expect(wrapper.text()).not.toContain(tApp('riskRules.exportTitle')) + expect(store.notice.message).toBe(tApp('auth.notice.signInForRiskRules')) + }) + + it('still lets pro users open the custom rule form', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.newRule'))!.trigger('click') + + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules-create' }) + }) + + it('allows custom rule saves for free users when opening the form directly', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-create' + listUserRulesMock.mockResolvedValue([]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find(`input[placeholder="${tApp('riskRules.form.ruleNameHint')}"]`).setValue('Blocked Rule') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(addRuleMock).toHaveBeenCalledTimes(1) + expect(wrapper.text()).not.toContain(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) + + it('blocks logged-out users from saving a custom risk rule from a direct form URL', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_guest', + session: null, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-create' + listUserRulesMock.mockResolvedValue([]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find(`input[placeholder="${tApp('riskRules.form.ruleNameHint')}"]`).setValue('Guest Rule') + await wrapper.find(`input[placeholder="${tApp('riskRules.form.reasonHint')}"]`).setValue('Guest reason') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(addRuleMock).not.toHaveBeenCalled() + expect(pushMock).not.toHaveBeenCalledWith({ name: 'risk-rules' }) + expect(wrapper.text()).toContain(tApp('auth.notice.signInForRiskRules')) + }) + + it('lets pro users save a custom rule without runtime riskengine errors', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-create' + listUserRulesMock.mockResolvedValue([]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find(`input[placeholder="${tApp('riskRules.form.ruleNameHint')}"]`).setValue('Allowed Rule') + await wrapper.find(`input[placeholder="${tApp('riskRules.form.reasonHint')}"]`).setValue('Allowed reason') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(addRuleMock).toHaveBeenCalledTimes(1) + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules' }) + expect(wrapper.text()).not.toContain('riskengine is not defined') + }) + + it('restores Redis specific commands when editing a custom rule', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-edit' + routeState.params = { id: 'redis-pd-delete' } + routeState.query = { kind: 'custom' } + listRulesMock.mockResolvedValue([ + { + id: 'redis-pd-delete', + builtin: false, + enabled: true, + description: 'Protect pd keys', + action: 'warn', + reason: 'pd delete review', + priority: 90, + scope: { dsTypes: ['redis'], keyPattern: 'pd:*' }, + when: { command: ['del'] }, + }, + ]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const specific = wrapper.find(`input[placeholder="${tApp('riskRules.form.redisSpecificHint')}"]`) + const keyPattern = wrapper.find(`input[placeholder="${tApp('riskRules.form.keyPatternHint')}"]`) + expect((specific.element as HTMLInputElement).value).toBe('DEL') + expect((keyPattern.element as HTMLInputElement).value).toBe('pd:*') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + const [, payload] = updateRuleMock.mock.calls[0] + expect(payload.when.command).toEqual(['del']) + expect(payload.scope.keyPattern).toBe('pd:*') + }) + + it('keeps SQL commands selected when editing a mixed Redis rule', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-edit' + routeState.params = { id: 'mixed-delete' } + routeState.query = { kind: 'custom' } + listRulesMock.mockResolvedValue([ + { + id: 'mixed-delete', + builtin: false, + enabled: true, + description: 'Mixed delete review', + action: 'warn', + reason: 'mixed rule', + priority: 50, + scope: { dsTypes: ['mysql', 'redis'] }, + when: { command: ['delete'] }, + }, + ]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const specific = wrapper.find(`input[placeholder="${tApp('riskRules.form.redisSpecificHint')}"]`) + const deleteChip = wrapper.findAll('button.risk-chip').find((btn) => btn.text() === 'DELETE') + expect((specific.element as HTMLInputElement).value).toBe('') + expect(deleteChip?.classes()).toContain('selected') + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + const [, payload] = updateRuleMock.mock.calls[0] + expect(payload.when.command).toEqual(['delete']) + expect(payload.scope.dsTypes).toEqual(['mysql', 'redis']) + }) + + it('lets pro users edit builtin probe thresholds', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-edit' + routeState.params = { id: 'probe-wide-scan' } + routeState.query = { kind: 'builtin' } + listRulesMock.mockResolvedValue([ + { + id: 'probe-wide-scan', + code: 'CR-999', + builtin: false, + enabled: true, + description: 'Colliding custom rule', + action: 'warn', + reason: 'custom reason', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 9999 }, + }, + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 1000 }, + }, + ]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('input[placeholder="1000"]').setValue('250') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(updateBuiltinProbeRuleThresholdsMock).toHaveBeenCalledWith('probe-wide-scan', expect.objectContaining({ maxExaminedRows: 250 })) + expect(updateRuleMock).not.toHaveBeenCalled() + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules' }) + }) + + it('blocks signed-in free users from saving builtin probe threshold edits from a direct URL', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_free', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_free', email: 'free@example.com', displayName: 'Free User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-edit' + routeState.params = { id: 'probe-wide-scan' } + routeState.query = { kind: 'builtin' } + listRulesMock.mockResolvedValue([ + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 1000 }, + }, + ]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('input[placeholder="1000"]').setValue('250') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(updateBuiltinProbeRuleThresholdsMock).not.toHaveBeenCalled() + expect(wrapper.text()).toContain(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) + + it('skips empty builtin probe numeric thresholds when saving', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + routeState.name = 'risk-rules-edit' + routeState.params = { id: 'probe-wide-scan' } + routeState.query = { kind: 'builtin' } + listRulesMock.mockResolvedValue([ + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + scope: { dsTypes: ['mysql'] }, + thresholds: { maxExaminedRows: 1000 }, + }, + ]) + + const wrapper = mount(RiskRulesFormView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.find('input[placeholder="1000"]').setValue('') + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.form.save'))!.trigger('click') + await flushPromises() + + expect(updateBuiltinProbeRuleThresholdsMock).toHaveBeenCalledTimes(1) + const [, payload] = updateBuiltinProbeRuleThresholdsMock.mock.calls[0] + expect(payload.maxExaminedRows).toBeUndefined() + expect(updateRuleMock).not.toHaveBeenCalled() + expect(pushMock).toHaveBeenCalledWith({ name: 'risk-rules' }) + }) + + it('lets pro users import custom rules without runtime riskengine errors', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.import'))!.trigger('click') + await flushPromises() + await wrapper.find('textarea.risk-import-textarea').setValue('[{\"description\":\"Imported\",\"reason\":\"Imported reason\",\"action\":\"warn\"}]') + await wrapper.find('.dialog-card button.btn:not(.secondary)').trigger('click') + await flushPromises() + + expect(addRuleMock).toHaveBeenCalledTimes(1) + expect(wrapper.text()).not.toContain('riskengine is not defined') + }) + + it('surfaces backend plan-limit failures during import instead of silently closing the dialog', async () => { + const store = useAppStore() + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + addRuleMock.mockRejectedValueOnce(new Error('plan_limit_exceeded:risk_rules:free:0')) + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + await wrapper.findAll('button').find((btn) => btn.text() === tApp('riskRules.import'))!.trigger('click') + await flushPromises() + await wrapper.find('textarea.risk-import-textarea').setValue('[{\"description\":\"Imported\",\"reason\":\"Imported reason\",\"action\":\"warn\"}]') + await wrapper.find('.dialog-card button.btn:not(.secondary)').trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain(tApp('riskRules.importTitle')) + expect(wrapper.text()).toContain(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + expect(store.notice.message).toBe(tApp('plan.notice.riskRules', { plan: tApp('plan.name.free') })) + }) + + it('shows rule codes in the list UI', async () => { + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('RR-001') + expect(wrapper.text()).toContain('CR-001') + }) + + it('lets pro users toggle both custom and built-in rules from the list', async () => { + const authStore = useAuthStore() + authStore.state = { + deviceId: 'device_pro', + session: { + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: Date.now() + 60_000, + user: { id: 'user_pro', email: 'pro@example.com', displayName: 'Pro User', avatarUrl: '' }, + license: { plan: 'pro', status: 'active', expiresAt: 0 }, + }, + pendingLogin: null, + } as any + + const wrapper = mount(RiskRulesView, { + global: { + plugins: [pinia], + }, + }) + await flushPromises() + + const toggles = wrapper.findAll('button.risk-toggle') + expect(toggles).toHaveLength(2) + + await toggles[0]!.trigger('click') + await toggles[1]!.trigger('click') + + expect(setEnabledMock).toHaveBeenCalledTimes(1) + expect(setEnabledMock).toHaveBeenCalledWith('user-rule-1', false) + expect(setBuiltinEnabledMock).toHaveBeenCalledTimes(1) + expect(setBuiltinEnabledMock).toHaveBeenCalledWith('builtin-1', false) + }) +}) diff --git a/frontend/src/__tests__/risk-rules-visibility.test.ts b/frontend/src/__tests__/risk-rules-visibility.test.ts new file mode 100644 index 0000000..501a94c --- /dev/null +++ b/frontend/src/__tests__/risk-rules-visibility.test.ts @@ -0,0 +1,77 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const pushMock = vi.fn() +const listRulesMock = vi.fn() +const listUserRulesMock = vi.fn() +const setBuiltinEnabledMock = vi.fn() +const updateBuiltinProbeRuleThresholdsMock = vi.fn() +const probeRule = { + id: 'probe-no-index', + code: 'PRB-003', + builtin: true, + enabled: true, + description: 'Warn when the execution plan does not show index usage', + action: 'warn', + reason: 'no index detected', + scope: { dsTypes: ['mysql', 'postgresql', 'd1', 'mongodb'] }, + thresholds: { + seqScanRowsThreshold: 10000, + costThreshold: 1000, + }, +} + +vi.mock('vue-router', () => ({ + useRoute: () => ({ name: 'risk-rules', params: {} }), + useRouter: () => ({ push: pushMock }), +})) + +vi.mock('@wailsjs/go/main/App', () => ({ + RiskEngineListRules: (...args: any[]) => listRulesMock(...args), + RiskEngineListUserRules: (...args: any[]) => listUserRulesMock(...args), + RiskEngineAddRule: vi.fn(), + RiskEngineUpdateRule: vi.fn(), + RiskEngineDeleteRule: vi.fn(), + RiskEngineSetBuiltinEnabled: (...args: any[]) => setBuiltinEnabledMock(...args), + RiskEngineUpdateBuiltinProbeRuleThresholds: (...args: any[]) => updateBuiltinProbeRuleThresholdsMock(...args), + RiskEngineSetEnabled: vi.fn(), +})) + +import RiskRulesView from '@/views/RiskRulesView.vue' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('risk rules visibility', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + pushMock.mockReset() + listRulesMock.mockReset() + listUserRulesMock.mockReset() + setBuiltinEnabledMock.mockReset() + listRulesMock.mockResolvedValue([probeRule]) + listUserRulesMock.mockResolvedValue([]) + }) + + it('renders probe rules with threshold details and no toggle action', async () => { + const wrapper = mount(RiskRulesView, { + global: { + plugins: [createPinia()], + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('PRB-003') + expect(wrapper.text()).toContain(tApp('riskRules.builtin.PRB-003.title')) + expect(wrapper.text()).toContain(tApp('riskRules.builtin.PRB-003.summary')) + expect(wrapper.text()).toContain(tApp('riskRules.triggerLabel')) + expect(wrapper.text()).toContain(tApp('riskRules.builtin.PRB-003.trigger')) + expect(wrapper.text()).toContain(`${tApp('riskRules.form.seqScanRowsThreshold')}: ${probeRule.thresholds.seqScanRowsThreshold}`) + expect(wrapper.text()).toContain(`${tApp('riskRules.form.costThreshold')}: ${probeRule.thresholds.costThreshold}`) + expect(wrapper.findAll('button').some((btn) => btn.text() === tApp('common.edit'))).toBe(true) + expect(wrapper.find(`button[aria-label="${tApp('riskRules.disableRule')}"]`).exists()).toBe(false) + expect(setBuiltinEnabledMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/row-mutation-dialog.test.ts b/frontend/src/__tests__/row-mutation-dialog.test.ts new file mode 100644 index 0000000..7e4d933 --- /dev/null +++ b/frontend/src/__tests__/row-mutation-dialog.test.ts @@ -0,0 +1,122 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import RowMutationDialog from '@/views/console/components/RowMutationDialog.vue' +import { setAppLocale } from '@/modules/i18n/appI18n' + +const sampleProps = { + open: true, + kind: 'delete' as const, + tableName: 'users', + pkSummary: 'id = 42', + statement: "DELETE FROM `users` WHERE `id` = 42;", +} + +describe('RowMutationDialog', () => { + beforeEach(() => { + setAppLocale('en') + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('renders delete dialog with statement and primary key summary', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: sampleProps, + }) + await flushPromises() + + const root = wrapper.find('[data-testid="row-mutation-delete-dialog"]') + expect(root.exists()).toBe(true) + + expect(wrapper.find('[data-testid="row-mutation-table"]').text()).toBe('users') + expect(wrapper.find('[data-testid="row-mutation-pk"]').text()).toBe('id = 42') + expect(wrapper.find('[data-testid="row-mutation-statement"]').text()).toContain('DELETE FROM') + + const confirm = wrapper.find('[data-testid="row-mutation-confirm-delete"]') + expect(confirm.exists()).toBe(true) + expect(confirm.classes()).toContain('danger') + }) + + it('renders update dialog with a non-danger confirm button', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: { + ...sampleProps, + kind: 'update' as const, + statement: "UPDATE `users` SET `name` = 'neo' WHERE `id` = 42;", + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="row-mutation-update-dialog"]').exists()).toBe(true) + const confirm = wrapper.find('[data-testid="row-mutation-confirm-update"]') + expect(confirm.exists()).toBe(true) + expect(confirm.classes()).not.toContain('danger') + expect(wrapper.find('[data-testid="row-mutation-statement"]').text()).toContain('UPDATE') + }) + + it('emits confirm and cancel from action buttons', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: sampleProps, + }) + await flushPromises() + + await wrapper.find('[data-testid="row-mutation-confirm-delete"]').trigger('click') + await wrapper.find('[data-testid="row-mutation-cancel"]').trigger('click') + + expect(wrapper.emitted('confirm')?.length).toBe(1) + expect(wrapper.emitted('cancel')?.length).toBe(1) + }) + + it('disables action buttons while busy is true', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: { ...sampleProps, busy: true }, + }) + await flushPromises() + + const confirm = wrapper.find('[data-testid="row-mutation-confirm-delete"]').element as HTMLButtonElement + const cancel = wrapper.find('[data-testid="row-mutation-cancel"]').element as HTMLButtonElement + expect(confirm.disabled).toBe(true) + expect(cancel.disabled).toBe(true) + }) + + it('emits cancel when pressing Escape while open', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: sampleProps, + }) + await flushPromises() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await flushPromises() + expect(wrapper.emitted('cancel')?.length).toBe(1) + }) + + it('renders Chinese strings when locale is zh', async () => { + setAppLocale('zh') + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: sampleProps, + }) + await flushPromises() + + expect(wrapper.find('h4').text()).toContain('删除') + const confirm = wrapper.find('[data-testid="row-mutation-confirm-delete"]') + expect(confirm.text()).toContain('删除') + }) + + it('does not render when open is false', async () => { + const wrapper = mount(RowMutationDialog, { + attachTo: document.body, + props: { ...sampleProps, open: false }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="row-mutation-delete-dialog"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/schema-privacy-panel.test.ts b/frontend/src/__tests__/schema-privacy-panel.test.ts new file mode 100644 index 0000000..77a93fa --- /dev/null +++ b/frontend/src/__tests__/schema-privacy-panel.test.ts @@ -0,0 +1,186 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listConsentsMock = vi.fn() +const setConsentMock = vi.fn() +const listAuditMock = vi.fn() + +vi.mock('@/services/api/schemaPrivacy', () => ({ + schemaPrivacyApi: { + listConsents: (...args: any[]) => listConsentsMock(...args), + getConsent: vi.fn(), + setConsent: (...args: any[]) => setConsentMock(...args), + listAudit: (...args: any[]) => listAuditMock(...args), + }, +})) + +import SchemaPrivacyPanel from '@/components/sensitivity/SchemaPrivacyPanel.vue' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('SchemaPrivacyPanel', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + listConsentsMock.mockReset() + setConsentMock.mockReset() + listAuditMock.mockReset() + }) + + it('renders one row per datasource and reflects the stored consent state', async () => { + listConsentsMock.mockResolvedValue({ + items: [ + { datasourceId: 'ds-1', datasourceName: 'Prod MySQL', datasourceType: 'mysql', consent: '' }, + { datasourceId: 'ds-2', datasourceName: 'Dev PG', datasourceType: 'postgresql', consent: 'allowed' }, + { datasourceId: 'ds-3', datasourceName: 'Refused', datasourceType: 'mysql', consent: 'denied' }, + ], + }) + + const wrapper = mount(SchemaPrivacyPanel) + await flushPromises() + + const items = wrapper.findAll('.schema-privacy-panel__item') + expect(items).toHaveLength(3) + expect(items[0].classes()).toContain('schema-privacy-panel__item--unset') + expect(items[1].classes()).toContain('schema-privacy-panel__item--allowed') + expect(items[2].classes()).toContain('schema-privacy-panel__item--denied') + + // The "schema vs row" distinction note must show — that's the explicit + // doc copy the task acceptance criteria require. + expect(wrapper.text()).toContain(tApp('sensitivity.schemaEgress.distinction.title')) + }) + + it('persists consent changes via setConsent and updates the row class', async () => { + listConsentsMock.mockResolvedValue({ + items: [ + { datasourceId: 'ds-1', datasourceName: 'Prod MySQL', datasourceType: 'mysql', consent: '' }, + ], + }) + setConsentMock.mockResolvedValue({ datasourceId: 'ds-1', consent: 'allowed' }) + + const wrapper = mount(SchemaPrivacyPanel) + await flushPromises() + + const allowedBtn = wrapper.find('.schema-privacy-panel__segment-btn--allowed') + await allowedBtn.trigger('click') + await flushPromises() + + expect(setConsentMock).toHaveBeenCalledWith('ds-1', 'allowed') + const item = wrapper.find('.schema-privacy-panel__item') + expect(item.classes()).toContain('schema-privacy-panel__item--allowed') + }) + + it('renders RFC3339 lastSentAt as a parseable timestamp, not "Invalid Date"', async () => { + // Regression for the codex P2 review on PR #379: the panel's old + // formatTimestamp multiplied the value by 1000, but the Go backend ships + // ISO strings (schemaprivacy.AuditEntry.CreatedAt). NaN * Date became + // "Invalid Date" the moment any datasource had history. + listConsentsMock.mockResolvedValue({ + items: [ + { + datasourceId: 'ds-with-history', + datasourceName: 'Has History', + datasourceType: 'mysql', + consent: 'allowed', + lastSentAt: '2026-04-29T12:34:56Z', + lastStatus: 'allowed', + }, + ], + }) + + const wrapper = mount(SchemaPrivacyPanel) + await flushPromises() + + const rendered = wrapper.find('.schema-privacy-panel__last').text() + expect(rendered).not.toContain('Invalid Date') + expect(rendered).not.toContain('NaN') + // Must show the localized year — proves the ISO string was parsed. + expect(rendered).toMatch(/2026/) + }) + + it('shows the never-sent label when the datasource has never sent metadata', async () => { + listConsentsMock.mockResolvedValue({ + items: [ + { datasourceId: 'ds-1', datasourceName: 'Prod', datasourceType: 'mysql', consent: '' }, + ], + }) + + const wrapper = mount(SchemaPrivacyPanel) + await flushPromises() + + expect(wrapper.text()).toContain(tApp('sensitivity.schemaEgress.neverSent')) + }) + + it('localizes raw trigger source enums in the audit table instead of leaking backend identifiers', async () => { + // Regression for the codex P2 review on PR #379: the audit table used to + // render `entry.triggerSource` directly, dumping internal enum values like + // `ai_chat_get_schema_knowledge` to end users. + listConsentsMock.mockResolvedValue({ + items: [ + { datasourceId: 'ds-1', datasourceName: 'Prod', datasourceType: 'mysql', consent: 'allowed' }, + ], + }) + listAuditMock.mockResolvedValue({ + items: [ + { + id: 'a1', + datasourceId: 'ds-1', + datasourceName: 'Prod', + triggerSource: 'ai_chat_get_schema_knowledge', + status: 'allowed', + entityCount: 3, + fieldCount: 12, + createdAt: '2026-04-29T10:00:00Z', + }, + ], + }) + + const wrapper = mount(SchemaPrivacyPanel) + await flushPromises() + + const details = wrapper.find('details.schema-privacy-panel__audit') + // Force the audit panel open and trigger the load. + ;(details.element as HTMLDetailsElement).open = true + await details.trigger('toggle') + await flushPromises() + + const rendered = wrapper.text() + expect(rendered).toContain(tApp('sensitivity.schemaEgress.trigger.ai_chat_get_schema_knowledge')) + expect(rendered).not.toContain('ai_chat_get_schema_knowledge') + }) + + it('exposes a labeled radiogroup with arrow-key navigation between consent options', async () => { + // Regression for the codex P2 review on PR #379: the consent selector + // declared role="radiogroup" / role="radio" but offered no group label + // and no keyboard interaction, leaving screen-reader and keyboard-only + // users without a way to identify or change the value. + listConsentsMock.mockResolvedValue({ + items: [ + { datasourceId: 'ds-1', datasourceName: 'Prod MySQL', datasourceType: 'mysql', consent: '' }, + ], + }) + setConsentMock.mockResolvedValue({ datasourceId: 'ds-1', consent: 'allowed' }) + + const wrapper = mount(SchemaPrivacyPanel, { attachTo: document.body }) + await flushPromises() + + const group = wrapper.find('[role="radiogroup"]') + const ariaLabel = group.attributes('aria-label') + expect(ariaLabel).toBeTruthy() + expect(ariaLabel).toContain('Prod MySQL') + + // First option ("unset") owns the tab stop because nothing is selected. + const radios = wrapper.findAll('[role="radio"]') + expect(radios[0].attributes('tabindex')).toBe('0') + expect(radios[1].attributes('tabindex')).toBe('-1') + expect(radios[2].attributes('tabindex')).toBe('-1') + + // ArrowRight must select+focus the next option (the radio-group pattern). + await radios[0].trigger('keydown', { key: 'ArrowRight' }) + await flushPromises() + expect(setConsentMock).toHaveBeenCalledWith('ds-1', 'allowed') + + wrapper.unmount() + }) +}) diff --git a/frontend/src/__tests__/scrolling-utils.test.ts b/frontend/src/__tests__/scrolling-utils.test.ts new file mode 100644 index 0000000..8368b5e --- /dev/null +++ b/frontend/src/__tests__/scrolling-utils.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' + +import { firstVisibleRowIndex } from '@/utils/scrolling' + +describe('firstVisibleRowIndex', () => { + it('accounts for sticky header height when determining the first visible row', () => { + const rows = [ + { index: 999, offsetTop: 36991, offsetHeight: 37 }, + { index: 1000, offsetTop: 37028, offsetHeight: 37 }, + ] + + const index = firstVisibleRowIndex(rows, 37000, 28) + + expect(index).toBe(1000) + }) +}) diff --git a/frontend/src/__tests__/sensitivity-api.test.ts b/frontend/src/__tests__/sensitivity-api.test.ts new file mode 100644 index 0000000..e2d4f02 --- /dev/null +++ b/frontend/src/__tests__/sensitivity-api.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { sensitivityApi } from '@/services/api/sensitivity' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('sensitivity api dev fallback', () => { + beforeEach(async () => { + resetAppI18nForTest() + setAppLocale('en') + await sensitivityApi.setCustomRules('') + await sensitivityApi.resetLevelConfig() + }) + + it('localizes default level config through app i18n keys in mock mode', async () => { + setAppLocale('zh') + + const config = await sensitivityApi.getLevelConfig() + + expect(config.levels[0].name).toBe(tApp('sensitivity.levelDef.L1.name')) + expect(config.levels[0].description).toBe(tApp('sensitivity.levelDef.L1.desc')) + expect(config.levels[0].name).not.toBe('Public') + }) + + it('allows custom rules and level config writes when Wails bindings are unavailable', async () => { + expect((window as any).go).toBeUndefined() + + await expect(sensitivityApi.setCustomRules('mask email columns')).resolves.toEqual({ ok: true }) + await expect(sensitivityApi.getCustomRules()).resolves.toEqual({ rules: 'mask email columns' }) + + const levels = [{ id: 1, key: 'L1', name: 'Public', description: 'Public data', color: 'green' }] + await expect(sensitivityApi.setLevelConfig(JSON.stringify(levels), 0, 0)).resolves.toEqual({ ok: true }) + await expect(sensitivityApi.getLevelConfig()).resolves.toMatchObject({ + levels, + agentAccessFrom: 0, + agentAccessTo: 0, + }) + }) +}) diff --git a/frontend/src/__tests__/sensitivity-css.test.ts b/frontend/src/__tests__/sensitivity-css.test.ts new file mode 100644 index 0000000..73bf1ba --- /dev/null +++ b/frontend/src/__tests__/sensitivity-css.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { resolve } from 'path' + +/** + * CSS regression tests for sensitivity pages. + * Ensures no hard-coded max-width constraints re-appear on top-level containers. + */ + +const viewsDir = resolve(__dirname, '../views') + +describe('SensitivityListView responsive layout', () => { + const src = readFileSync(resolve(viewsDir, 'SensitivityListView.vue'), 'utf-8') + const style = src.match(/]*>([\s\S]*?)<\/style>/)?.[1] ?? '' + + it('should not have a max-width on .sensitivity-list-view', () => { + // Extract the .sensitivity-list-view rule block + const ruleMatch = style.match(/\.sensitivity-list-view\s*\{([^}]*)\}/) + expect(ruleMatch).toBeTruthy() + const rule = ruleMatch![1] + + expect(rule).not.toMatch(/max-width\s*:/) + }) + + it('keeps sensitivity configuration icon controls at 32px tap targets', () => { + const colorTrigger = style.match(/\.sens-level__color-trigger\s*\{[\s\S]*?\}/)?.[0] ?? '' + const colorDot = style.match(/\.sens-level__color-dot\s*\{[\s\S]*?\}/)?.[0] ?? '' + const deleteButton = style.match(/\.sens-level__delete\s*\{[\s\S]*?\}/)?.[0] ?? '' + const addTag = style.match(/\.sens-level__tag-add\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(colorTrigger).toMatch(/width:\s*32px/i) + expect(colorTrigger).toMatch(/height:\s*32px/i) + expect(colorDot).toMatch(/width:\s*32px/i) + expect(colorDot).toMatch(/height:\s*32px/i) + expect(deleteButton).toMatch(/width:\s*32px/i) + expect(deleteButton).toMatch(/height:\s*32px/i) + expect(addTag).toMatch(/width:\s*32px/i) + expect(addTag).toMatch(/height:\s*32px/i) + }) +}) + +describe('SensitivityView responsive layout', () => { + const src = readFileSync(resolve(viewsDir, 'SensitivityView.vue'), 'utf-8') + + it('should not have max-w-* Tailwind constraint on root section', () => { + // Find the root
tag in the template + const sectionMatch = src.match(/]*class="([^"]*)"/) + expect(sectionMatch).toBeTruthy() + const classes = sectionMatch![1] + + expect(classes).not.toMatch(/max-w-/) + }) + + it('keeps the scan action at a 32px tap target', () => { + expect(src).toContain('min-h-[32px] px-4 py-2 rounded-lg bg-primary') + }) + + it('keeps field override actions at 32px tap targets', () => { + const matches = src.match(/inline-flex min-h-\[32px\] items-center text-xs text-primary hover:underline/g) || [] + + expect(matches.length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/frontend/src/__tests__/sensitivity-view.test.ts b/frontend/src/__tests__/sensitivity-view.test.ts new file mode 100644 index 0000000..a542803 --- /dev/null +++ b/frontend/src/__tests__/sensitivity-view.test.ts @@ -0,0 +1,241 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const pushMock = vi.fn() +const eventsOnMock = vi.fn(() => () => {}) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ params: { id: 'ds_pg' } }), + useRouter: () => ({ push: pushMock }), +})) + +vi.mock('@wailsjs/runtime/runtime', () => ({ + EventsOn: (...args: any[]) => eventsOnMock(...args), +})) + +vi.mock('@/services/api/sensitivity', () => ({ + sensitivityApi: { + getReport: vi.fn(), + getCustomRules: vi.fn(), + setCustomRules: vi.fn(), + scan: vi.fn(), + getProgress: vi.fn(), + confirmField: vi.fn(), + getMode: vi.fn(), + setMode: vi.fn(), + deleteDatasource: vi.fn(), + getLevelConfig: vi.fn(), + }, +})) + +vi.mock('@/services/api/aiconfig', () => ({ + aiApi: { + listAIConfigs: vi.fn(), + }, +})) + +import SensitivityView from '@/views/SensitivityView.vue' +import { sensitivityApi } from '@/services/api/sensitivity' +import { aiApi } from '@/services/api/aiconfig' +import { useAuthStore } from '@/stores/auth' +import { tApp } from '@/modules/i18n/appI18n' + +const baseReport = { + found: true, + datasourceId: 'ds_pg', + scannedAt: 1710000000, + entities: { + A: { + fields: { + email: { + level: 'L4', + category: 'pii', + reason: 'Detected contact field', + source: 'ai', + }, + }, + }, + }, +} + +describe('SensitivityView', () => { + let pinia: ReturnType + + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + vi.clearAllMocks() + ;(window as any).runtime = undefined + const authStore = useAuthStore() + authStore.state.session = { + accessToken: 'access_1', + refreshToken: 'refresh_1', + expiresAt: Date.now() + 60_000, + user: { id: 'user_1', email: 'user@example.com', displayName: 'Sensitivity User', avatarUrl: '' }, + license: { plan: 'free', status: 'active', expiresAt: 0 }, + } as any + vi.mocked(aiApi.listAIConfigs).mockResolvedValue([ + { id: 'ai_1', name: 'OpenRouter', provider: 'openrouter', model: 'gpt-5.4', status: 'connected' }, + ] as any) + vi.mocked(sensitivityApi.getCustomRules).mockResolvedValue({ rules: '' } as any) + vi.mocked(sensitivityApi.setCustomRules).mockResolvedValue({ ok: true } as any) + vi.mocked(sensitivityApi.getLevelConfig).mockResolvedValue({ + levels: [ + { id: 1, key: 'L1', name: 'Public', description: '', examples: [], color: 'green' }, + { id: 2, key: 'L2', name: 'Internal', description: '', examples: [], color: 'blue' }, + { id: 3, key: 'L3', name: 'Confidential', description: '', examples: [], color: 'yellow' }, + { id: 4, key: 'L4', name: 'Sensitive', description: '', examples: [], color: 'orange' }, + { id: 5, key: 'L5', name: 'Critical', description: '', examples: [], color: 'red' }, + ], + agentAccessFrom: 1, + agentAccessTo: 3, + } as any) + }) + + afterEach(() => { + ;(window as any).runtime = undefined + }) + + it('keeps the scan failure message visible after the report reloads', async () => { + vi.mocked(sensitivityApi.getReport) + .mockResolvedValueOnce({ found: false } as any) + .mockResolvedValueOnce({ found: false } as any) + vi.mocked(sensitivityApi.scan).mockResolvedValue({ status: 'started', datasourceId: 'ds_pg' } as any) + vi.mocked(sensitivityApi.getProgress).mockResolvedValue({ + status: 'failed', + error: 'parse AI response: unexpected end of JSON input', + datasourceId: 'ds_pg', + scannedEntities: 0, + totalEntities: 27, + } as any) + + const wrapper = mount(SensitivityView, { global: { plugins: [pinia] } }) + await flushPromises() + + const scanButton = wrapper.findAll('button').find((item) => item.text().includes('Scan'))! + await scanButton.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('parse AI response: unexpected end of JSON input') + }) + + it('restores the previous scroll position after confirming a field override', async () => { + const host = document.createElement('div') + host.className = 'app-content' + host.style.height = '400px' + document.body.appendChild(host) + Object.defineProperty(host, 'clientHeight', { value: 400, configurable: true }) + Object.defineProperty(host, 'scrollHeight', { value: 1600, configurable: true }) + host.scrollTop = 640 + + vi.mocked(sensitivityApi.getReport) + .mockResolvedValueOnce(baseReport as any) + .mockImplementationOnce(async () => { + host.scrollTop = 0 + return { + ...baseReport, + entities: { + A: { + fields: { + email: { + level: 'L4', + category: 'contact', + reason: 'Manually updated', + source: 'manual', + }, + }, + }, + }, + } as any + }) + vi.mocked(sensitivityApi.confirmField).mockResolvedValue({ ok: true } as any) + + try { + const wrapper = mount(SensitivityView, { attachTo: host, global: { plugins: [pinia] } }) + await flushPromises() + + const entityRow = wrapper.findAll('tbody tr').find((item) => item.text().includes('A')) + expect(entityRow).toBeTruthy() + await entityRow!.trigger('click') + await flushPromises() + + const overrideButton = wrapper.findAll('button').find((item) => item.text().includes('Override')) + expect(overrideButton).toBeTruthy() + await overrideButton!.trigger('click') + await flushPromises() + + const confirmButton = wrapper.findAll('button').find((item) => item.text() === 'Confirm') + expect(confirmButton).toBeTruthy() + await confirmButton!.trigger('click') + await flushPromises() + + expect(host.scrollTop).toBe(640) + } finally { + host.remove() + } + }) + + it('blocks logged-out users from editing custom sensitivity rules', async () => { + const authStore = useAuthStore() + authStore.state.session = null as any + vi.mocked(sensitivityApi.getReport).mockResolvedValue({ found: false } as any) + + const wrapper = mount(SensitivityView, { global: { plugins: [pinia] } }) + await flushPromises() + + const customRules = wrapper.find('textarea') + expect((customRules.element as HTMLTextAreaElement).disabled).toBe(true) + expect(wrapper.text()).toContain(tApp('auth.notice.signInForSensitivityRules')) + + await customRules.trigger('blur') + await flushPromises() + + expect(sensitivityApi.setCustomRules).not.toHaveBeenCalled() + }) + + it('allows active-trial logged-out users to edit custom sensitivity rules', async () => { + const authStore = useAuthStore() + const nowSec = Math.floor(Date.now() / 1000) + authStore.state.session = null as any + authStore.state.trial = { startedAt: nowSec - 60, expiresAt: nowSec + 30 * 24 * 60 * 60 } + vi.mocked(sensitivityApi.getReport).mockResolvedValue({ found: false } as any) + + const wrapper = mount(SensitivityView, { global: { plugins: [pinia] } }) + await flushPromises() + + const customRules = wrapper.find('textarea') + expect((customRules.element as HTMLTextAreaElement).disabled).toBe(false) + expect(wrapper.text()).not.toContain(tApp('auth.notice.signInForSensitivityRules')) + + await customRules.setValue('mask email') + await customRules.trigger('blur') + await flushPromises() + + expect(sensitivityApi.setCustomRules).toHaveBeenCalledWith('mask email') + }) + + it('blocks logged-out users from overriding field sensitivity rules', async () => { + const authStore = useAuthStore() + authStore.state.session = null as any + vi.mocked(sensitivityApi.getReport).mockResolvedValue(baseReport as any) + + const wrapper = mount(SensitivityView, { global: { plugins: [pinia] } }) + await flushPromises() + + const entityRow = wrapper.findAll('tbody tr').find((item) => item.text().includes('A')) + expect(entityRow).toBeTruthy() + await entityRow!.trigger('click') + await flushPromises() + + const overrideButton = wrapper.findAll('button').find((item) => item.text().includes('Override')) + expect(overrideButton).toBeTruthy() + expect((overrideButton!.element as HTMLButtonElement).disabled).toBe(true) + + await overrideButton!.trigger('click') + await flushPromises() + + expect(wrapper.find('.fixed.inset-0').exists()).toBe(false) + expect(sensitivityApi.confirmField).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/__tests__/sidebar-history-entry.test.ts b/frontend/src/__tests__/sidebar-history-entry.test.ts new file mode 100644 index 0000000..698f020 --- /dev/null +++ b/frontend/src/__tests__/sidebar-history-entry.test.ts @@ -0,0 +1,31 @@ +import { mount, RouterLinkStub } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import Sidebar from '@/core/layout/Sidebar.vue' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ path: '/' }), +})) + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), +}) + +describe('Sidebar history entry', () => { + it('renders the History navigation item', () => { + const wrapper = mount(Sidebar, { + global: { + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }) + + expect(wrapper.text()).toContain('History') + }) +}) diff --git a/frontend/src/__tests__/sidebar-navigation.test.ts b/frontend/src/__tests__/sidebar-navigation.test.ts new file mode 100644 index 0000000..371360d --- /dev/null +++ b/frontend/src/__tests__/sidebar-navigation.test.ts @@ -0,0 +1,96 @@ +import { mount } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { describe, expect, it } from 'vitest' + +import Sidebar from '@/core/layout/Sidebar.vue' + +const Dummy = { template: '
' } + +describe('Sidebar navigation', () => { + it('renders History item together with main nav entries', async () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/history', name: 'history', component: Dummy }, + { path: '/sensitivity', name: 'sensitivity-list', component: Dummy }, + { path: '/risk-rules', name: 'risk-rules', component: Dummy }, + { path: '/ai-settings', name: 'ai-settings', component: Dummy }, + { path: '/my', name: 'my', component: Dummy }, + ], + }) + + await router.push('/') + await router.isReady() + + const wrapper = mount(Sidebar, { + global: { + plugins: [router], + }, + }) + + const linkTexts = wrapper.findAll('a').map((node) => node.text()) + expect(linkTexts.some((text) => text.includes('History'))).toBe(true) + expect(linkTexts.some((text) => text.includes('Sources'))).toBe(true) + expect(linkTexts.some((text) => text.includes('Data Sensitivity'))).toBe(true) + expect(linkTexts.some((text) => text.includes('AI Settings'))).toBe(true) + }) + + it('keeps route navigation active state and does not render deprecated sidebar logo entry', async () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/history', name: 'history', component: Dummy }, + { path: '/sensitivity', name: 'sensitivity-list', component: Dummy }, + { path: '/risk-rules', name: 'risk-rules', component: Dummy }, + { path: '/ai-settings', name: 'ai-settings', component: Dummy }, + { path: '/my', name: 'my', component: Dummy }, + ], + }) + + await router.push('/history') + await router.isReady() + + const wrapper = mount(Sidebar, { + global: { + plugins: [router], + }, + }) + + const activeLinks = wrapper.findAll('a.bg-primary\\/10') + expect(activeLinks.length).toBeGreaterThanOrEqual(1) + expect(activeLinks.some((link) => link.attributes('href') === '/history')).toBe(true) + + expect(wrapper.find('[data-testid="sidebar-logo-link"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="sidebar-logo-image"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/skill-install-dialog.test.ts b/frontend/src/__tests__/skill-install-dialog.test.ts new file mode 100644 index 0000000..73a5071 --- /dev/null +++ b/frontend/src/__tests__/skill-install-dialog.test.ts @@ -0,0 +1,207 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import SkillInstallDialog from '@/components/skill/SkillInstallDialog.vue' +import { api } from '@/services/api' +import { resetAppI18nForTest, setAppLocale } from '@/modules/i18n/appI18n' + +const mountDialog = () => + mount(SkillInstallDialog, { + global: { plugins: [createPinia()] }, + }) + +const stubDetect = (skillInstalled = false, mcpInstalled = false) => { + vi.spyOn(api, 'detectAIAgents').mockResolvedValue([ + { + id: 'claude', + name: 'Claude Code', + detected: true, + installed: skillInstalled, + installPath: '~/.claude/skills/futrixdata/SKILL.md', + }, + ] as any) + vi.spyOn(api, 'detectMCPAgents').mockResolvedValue([ + { + id: 'claude', + name: 'Claude Code', + detected: true, + installed: mcpInstalled, + configPath: '~/.claude/settings.json', + }, + ] as any) +} + +describe('SkillInstallDialog — sensitivity grant', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not call setAgentSensitivityGrant when the grant checkbox is unchecked', async () => { + stubDetect() + vi.spyOn(api, 'installSkill').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/skills/futrixdata/SKILL.md', success: true, accessKey: 'agent_skill_1' }], + } as any) + vi.spyOn(api, 'installMCP').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/settings.json', success: true, accessKey: 'agent_skill_1' }], + } as any) + const grantSpy = vi.spyOn(api, 'setAgentSensitivityGrant') + const datasourceGrantSpy = vi.spyOn(api, 'setAgentDatasourceManagementGrant') + + const wrapper = mountDialog() + await flushPromises() + + expect(wrapper.find('[data-testid="skill-install-approval-policy"]').text()).toContain('Third-party agents cannot approve') + + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + expect(grantSpy).not.toHaveBeenCalled() + expect(datasourceGrantSpy).not.toHaveBeenCalled() + expect(wrapper.find('[data-testid="skill-install-results"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="skill-install-grant-failures"]').exists()).toBe(false) + }) + + it('grants sensitivity once per identity even when skill+MCP both succeed for the same agent', async () => { + stubDetect() + vi.spyOn(api, 'installSkill').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/skills/futrixdata/SKILL.md', success: true, accessKey: 'agent_skill_dedupe' }], + } as any) + vi.spyOn(api, 'installMCP').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/settings.json', success: true, accessKey: 'agent_skill_dedupe' }], + } as any) + const grantSpy = vi.spyOn(api, 'setAgentSensitivityGrant').mockResolvedValue({} as any) + + const wrapper = mountDialog() + await flushPromises() + + await wrapper.find('[data-testid="skill-install-grant-input"]').setValue(true) + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + expect(grantSpy).toHaveBeenCalledTimes(1) + expect(grantSpy).toHaveBeenCalledWith('agent_skill_dedupe', true) + }) + + it('grants datasource management once per identity when selected', async () => { + stubDetect() + vi.spyOn(api, 'installSkill').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/skills/futrixdata/SKILL.md', success: true, accessKey: 'agent_datasource_dedupe' }], + } as any) + vi.spyOn(api, 'installMCP').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/settings.json', success: true, accessKey: 'agent_datasource_dedupe' }], + } as any) + const grantSpy = vi.spyOn(api, 'setAgentDatasourceManagementGrant').mockResolvedValue({} as any) + + const wrapper = mountDialog() + await flushPromises() + + await wrapper.find('[data-testid="skill-install-datasource-grant-input"]').setValue(true) + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + expect(grantSpy).toHaveBeenCalledTimes(1) + expect(grantSpy).toHaveBeenCalledWith('agent_datasource_dedupe', true) + }) + + it('surfaces a partial-failure banner when the grant write fails after a successful install', async () => { + stubDetect() + vi.spyOn(api, 'installSkill').mockResolvedValue({ + installed: [{ id: 'claude', name: 'Claude Code', path: '~/.claude/skills/futrixdata/SKILL.md', success: true, accessKey: 'agent_skill_grant_fail' }], + } as any) + vi.spyOn(api, 'installMCP').mockResolvedValue({ installed: [] } as any) + vi.spyOn(api, 'setAgentSensitivityGrant').mockRejectedValue(new Error('disk full')) + + const wrapper = mountDialog() + await flushPromises() + + await wrapper.find('[data-testid="skill-install-grant-input"]').setValue(true) + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + // Install itself still shows success — the agent IS installed; only the grant write failed. + expect(wrapper.find('[data-testid="skill-install-results"]').text()).toContain('Claude Code') + const partial = wrapper.find('[data-testid="skill-install-grant-failures"]') + expect(partial.exists()).toBe(true) + expect(partial.text()).toContain('disk full') + expect(partial.text()).toContain('Claude Code') + }) + + it('does not block install completion when only the grant write fails for one of multiple agents', async () => { + vi.spyOn(api, 'detectAIAgents').mockResolvedValue([ + { id: 'claude', name: 'Claude Code', detected: true, installed: false, installPath: '~/.claude/skills/futrixdata/SKILL.md' }, + { id: 'cursor', name: 'Cursor', detected: true, installed: false, installPath: '~/.cursor/rules/futrixdata.mdc' }, + ] as any) + vi.spyOn(api, 'detectMCPAgents').mockResolvedValue([] as any) + vi.spyOn(api, 'installSkill').mockResolvedValue({ + installed: [ + { id: 'claude', name: 'Claude Code', path: '~/.claude/skills/futrixdata/SKILL.md', success: true, accessKey: 'agent_claude_ok' }, + { id: 'cursor', name: 'Cursor', path: '~/.cursor/rules/futrixdata.mdc', success: true, accessKey: 'agent_cursor_grant_fail' }, + ], + } as any) + vi.spyOn(api, 'setAgentSensitivityGrant').mockImplementation(async (key: string) => { + if (key === 'agent_cursor_grant_fail') throw new Error('write conflict') + return {} as any + }) + + const wrapper = mountDialog() + await flushPromises() + + await wrapper.find('[data-testid="skill-install-grant-input"]').setValue(true) + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + const partial = wrapper.find('[data-testid="skill-install-grant-failures"]') + expect(partial.exists()).toBe(true) + expect(partial.text()).toContain('Cursor') + expect(partial.text()).toContain('write conflict') + // The Claude row should NOT appear in the failures list — its grant succeeded. + expect(partial.text()).not.toContain('Claude Code') + }) + + it('uses MCP-only setup for Codex plugin authorization', async () => { + vi.spyOn(api, 'detectAIAgents').mockResolvedValue([ + { + id: 'codex', + name: 'Codex', + detected: true, + installed: false, + installPath: '~/.codex/skills/futrixdata/SKILL.md', + }, + ] as any) + vi.spyOn(api, 'detectMCPAgents').mockResolvedValue([ + { + id: 'codex', + name: 'Codex', + detected: true, + installed: false, + configPath: '~/.codex/config.toml', + }, + ] as any) + const skillSpy = vi.spyOn(api, 'installSkill').mockResolvedValue({ installed: [] } as any) + const mcpSpy = vi.spyOn(api, 'installMCP').mockResolvedValue({ + installed: [{ id: 'codex', name: 'Codex', path: '~/.codex/config.toml', success: true, accessKey: 'agent_codex_1' }], + } as any) + + const wrapper = mountDialog() + await flushPromises() + + const setup = wrapper.find('[data-testid="skill-codex-setup"]') + expect(setup.exists()).toBe(true) + expect(setup.text()).toContain('Codex plugin') + expect(setup.text()).toContain('Use plugin setup') + + await wrapper.find('[data-testid="skill-install-confirm"]').trigger('click') + await flushPromises() + + expect(skillSpy).not.toHaveBeenCalled() + expect(mcpSpy).toHaveBeenCalledWith(['codex']) + expect(wrapper.find('[data-testid="skill-install-results"]').text()).toContain('Codex') + }) +}) diff --git a/frontend/src/__tests__/startup-recovery-css.test.ts b/frontend/src/__tests__/startup-recovery-css.test.ts new file mode 100644 index 0000000..f4bd4ea --- /dev/null +++ b/frontend/src/__tests__/startup-recovery-css.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const sourcePath = path.resolve(__dirname, '../components/startup/StartupRecoveryView.vue') +const source = fs.readFileSync(sourcePath, 'utf8') + +describe('startup recovery css', () => { + it('keeps recovery actions and confirmation controls stable across narrow layouts', () => { + const actionButtonRule = source.match(/\.startup-recovery__actions\s+\.btn\s*\{[\s\S]*?\}/)?.[0] ?? '' + expect(actionButtonRule).toMatch(/flex:\s*0\s+0\s+auto/i) + expect(actionButtonRule).toMatch(/white-space:\s*nowrap/i) + + const confirmInputRule = source.match(/\.startup-recovery__confirm\s+input\s*\{[\s\S]*?\}/)?.[0] ?? '' + expect(confirmInputRule).toMatch(/width:\s*16px/i) + expect(confirmInputRule).toMatch(/height:\s*16px/i) + expect(confirmInputRule).toMatch(/flex:\s*0\s+0\s+auto/i) + + expect(source).toContain('@media (max-width: 760px)') + expect(source).toMatch(/\.startup-recovery__panel\s*\{[\s\S]*?grid-template-columns:\s*1fr/i) + }) +}) diff --git a/frontend/src/__tests__/startup-recovery-view.test.ts b/frontend/src/__tests__/startup-recovery-view.test.ts new file mode 100644 index 0000000..9111ff9 --- /dev/null +++ b/frontend/src/__tests__/startup-recovery-view.test.ts @@ -0,0 +1,71 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import StartupRecoveryView from '@/components/startup/StartupRecoveryView.vue' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' +import { api } from '@/services/api' + +describe('StartupRecoveryView', () => { + beforeEach(() => { + resetAppI18nForTest() + setAppLocale('en') + vi.restoreAllMocks() + }) + + it('renders classified recovery actions without loading the normal shell', async () => { + const status = { + state: 'failed', + error: { + reason: 'key_mismatch', + message: 'The local encrypted data could not be opened with this device key.', + dataPath: '/tmp/FutrixData/datasources.json', + actions: ['retry', 'open_logs', 'move_aside_and_restart'], + }, + } + vi.spyOn(api, 'startupRecoveryRetry').mockResolvedValue({ state: 'failed', error: status.error } as any) + vi.spyOn(api, 'startupRecoveryOpenLogs').mockResolvedValue(undefined) + + const wrapper = mount(StartupRecoveryView, { + props: { status }, + }) + + expect(wrapper.text()).toContain(tApp('startupRecovery.title')) + expect(wrapper.text()).toContain(tApp('startupRecovery.reason.key_mismatch')) + expect(wrapper.text()).toContain('/tmp/FutrixData/datasources.json') + + await wrapper.get('[data-testid="startup-recovery-retry"]').trigger('click') + await flushPromises() + expect(api.startupRecoveryRetry).toHaveBeenCalledTimes(1) + }) + + it('requires explicit confirmation before moving old encrypted data aside', async () => { + const status = { + state: 'failed', + error: { + reason: 'corrupt_file', + message: 'The local encrypted data appears damaged.', + dataPath: '/tmp/FutrixData/datasources.json', + actions: ['move_aside_and_restart'], + }, + } + const moveAside = vi.spyOn(api, 'startupRecoveryMoveAsideAndRestart').mockResolvedValue({ + state: 'ready', + movedAside: { + retentionDir: '/tmp/FutrixData-recovered-20260502T100000Z', + }, + } as any) + + const wrapper = mount(StartupRecoveryView, { + props: { status }, + }) + + await wrapper.get('[data-testid="startup-recovery-move-aside"]').trigger('click') + expect(moveAside).not.toHaveBeenCalled() + + await wrapper.get('[data-testid="startup-recovery-confirm"]').setValue(true) + await wrapper.get('[data-testid="startup-recovery-move-aside"]').trigger('click') + await flushPromises() + + expect(moveAside).toHaveBeenCalledWith(true) + }) +}) diff --git a/frontend/src/__tests__/statement-editor-css.test.ts b/frontend/src/__tests__/statement-editor-css.test.ts new file mode 100644 index 0000000..bf78695 --- /dev/null +++ b/frontend/src/__tests__/statement-editor-css.test.ts @@ -0,0 +1,29 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('statement editor CSS', () => { + it('clips runnable statement gutter markers within the editor', () => { + const css = loadStyleCss() + const block = css.match(/\.statement-gutter\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('overflow: hidden') + }) + + it('positions legacy statement highlight as an overlay instead of document flow', () => { + const css = loadStyleCss() + const block = css.match(/\.statement-shell\s*>\s*\.statement-highlight\s*\{[\s\S]*?\}/)?.[0] ?? '' + + expect(block).toContain('position: absolute') + expect(block).toContain('inset: 0') + expect(block).toContain('margin: 0') + expect(block).toContain('pointer-events: none') + }) +}) diff --git a/frontend/src/__tests__/statement-ghost-style.test.ts b/frontend/src/__tests__/statement-ghost-style.test.ts new file mode 100644 index 0000000..7f4e4fb --- /dev/null +++ b/frontend/src/__tests__/statement-ghost-style.test.ts @@ -0,0 +1,18 @@ +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { readCssWithImports } from './helpers/read-css-with-imports' + +const loadStyleCss = () => { + const filePath = path.resolve(__dirname, '..', 'style.css') + return readCssWithImports(filePath) +} + +describe('statement ghost styling', () => { + it('avoids inheriting non-monospace font', () => { + const css = loadStyleCss() + + expect(css.includes('font: inherit;')).toBe(false) + }) +}) diff --git a/frontend/src/__tests__/theme-toggle-relocation.test.ts b/frontend/src/__tests__/theme-toggle-relocation.test.ts new file mode 100644 index 0000000..8688625 --- /dev/null +++ b/frontend/src/__tests__/theme-toggle-relocation.test.ts @@ -0,0 +1,39 @@ +import { mount, RouterLinkStub } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' + +import Sidebar from '@/core/layout/Sidebar.vue' +import TitleBar from '@/components/TitleBar.vue' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ path: '/', meta: { title: 'Data Sources' } }), +})) + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), +}) + +describe('theme toggle relocation', () => { + it('renders theme toggle in sidebar, not title bar', () => { + const pinia = createPinia() + setActivePinia(pinia) + + const titleBar = mount(TitleBar, { global: { plugins: [pinia] } }) + expect(titleBar.find('.theme-toggle').exists()).toBe(false) + + const sidebar = mount(Sidebar, { + global: { + plugins: [pinia], + stubs: { + RouterLink: RouterLinkStub, + }, + }, + }) + expect(sidebar.find('.theme-toggle').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/title-bar-ai-toggle.test.ts b/frontend/src/__tests__/title-bar-ai-toggle.test.ts new file mode 100644 index 0000000..039fece --- /dev/null +++ b/frontend/src/__tests__/title-bar-ai-toggle.test.ts @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import TitleBar from '@/components/TitleBar.vue' +import { useAiChatStore } from '@/stores/ai-chat' + +vi.mock('vue-router', () => ({ + useRoute: () => ({ meta: { title: 'Console' } }), +})) + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), +}) + +describe('title bar ai toggle', () => { + it('toggles ai sidebar open state', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useAiChatStore() + const wrapper = mount(TitleBar, { global: { plugins: [pinia] } }) + + expect(wrapper.find('[data-testid="ai-toggle"] .ai-toggle-icon').exists()).toBe(true) + + await wrapper.find('[data-testid="ai-toggle"]').trigger('click') + expect(store.isOpen).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/title-bar-logo.test.ts b/frontend/src/__tests__/title-bar-logo.test.ts new file mode 100644 index 0000000..edf8a4f --- /dev/null +++ b/frontend/src/__tests__/title-bar-logo.test.ts @@ -0,0 +1,45 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createMemoryHistory, createRouter } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it } from 'vitest' + +import TitleBar from '@/components/TitleBar.vue' +import { tApp } from '@/modules/i18n/appI18n' + +const Dummy = { template: '
' } + +describe('TitleBar branding', () => { + it('renders app logo and current route title in the header', async () => { + const pinia = createPinia() + setActivePinia(pinia) + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/history', + name: 'history', + component: Dummy, + meta: { titleKey: 'nav.history' }, + }, + ], + }) + + await router.push('/history') + await router.isReady() + + const wrapper = mount(TitleBar, { + global: { + plugins: [pinia, router], + }, + }) + + await flushPromises() + + const logo = wrapper.find('img[alt="FutrixData"]') + expect(logo.exists()).toBe(true) + expect(logo.attributes('src')).toBeTruthy() + expect(wrapper.text()).toContain('FutrixData') + expect(wrapper.text()).toContain(tApp('nav.history')) + }) +}) diff --git a/frontend/src/__tests__/trust-level-panel-normalize.test.ts b/frontend/src/__tests__/trust-level-panel-normalize.test.ts new file mode 100644 index 0000000..3a73524 --- /dev/null +++ b/frontend/src/__tests__/trust-level-panel-normalize.test.ts @@ -0,0 +1,66 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const listDatasourcesMock = vi.fn() +const setTrustLevelMock = vi.fn() + +vi.mock('@/services/api/datasources', () => ({ + datasourcesApi: { + listDatasources: (...args: any[]) => listDatasourcesMock(...args), + setDatasourceTrustLevel: (...args: any[]) => setTrustLevelMock(...args), + }, +})) + +import TrustLevelPanel from '@/components/riskRules/TrustLevelPanel.vue' +import { resetAppI18nForTest, setAppLocale, tApp } from '@/modules/i18n/appI18n' + +describe('TrustLevelPanel trust level normalization', () => { + beforeEach(() => { + setActivePinia(createPinia()) + resetAppI18nForTest() + setAppLocale('en') + listDatasourcesMock.mockReset() + setTrustLevelMock.mockReset() + }) + + // Regression for codex P2: the panel used to compare ds.options.trustLevel + // against the canonical lowercase set directly, so a stored value like + // "DANGER" (from a raw API write) would be treated as cautious while the + // backend still enforced danger. UI/backend mismatch hides the active mode. + it('treats non-canonical stored trust levels as the normalized mode', async () => { + listDatasourcesMock.mockResolvedValue([ + { id: 'ds-upper', name: 'Upper', type: 'mysql', options: { trustLevel: 'DANGER' } }, + { id: 'ds-pad', name: 'Padded', type: 'mysql', options: { trustLevel: ' approval ' } }, + ]) + + const wrapper = mount(TrustLevelPanel) + await flushPromises() + + // Each row carries an `trust-panel__item--` class reflecting the + // effective trust level. Normalized values must resolve to their canonical + // mode class, not fall back to cautious. + const items = wrapper.findAll('.trust-panel__item') + expect(items[0].classes()).toContain('trust-panel__item--danger') + expect(items[1].classes()).toContain('trust-panel__item--approval') + + // And the danger warning must appear because a datasource is effectively + // in danger mode — the normalization fix is what surfaces this. + expect(wrapper.text()).toContain(tApp('riskRules.trustLevels.warningDanger')) + }) + + it('falls back to cautious when the stored value is not recognizable', async () => { + listDatasourcesMock.mockResolvedValue([ + { id: 'ds-odd', name: 'Odd', type: 'mysql', options: { trustLevel: 'sandbox' } }, + { id: 'ds-missing', name: 'Missing', type: 'mysql', options: {} }, + ]) + + const wrapper = mount(TrustLevelPanel) + await flushPromises() + + const items = wrapper.findAll('.trust-panel__item') + expect(items[0].classes()).toContain('trust-panel__item--cautious') + expect(items[1].classes()).toContain('trust-panel__item--cautious') + expect(wrapper.text()).not.toContain(tApp('riskRules.trustLevels.warningDanger')) + }) +}) diff --git a/frontend/src/__tests__/updater-store.test.ts b/frontend/src/__tests__/updater-store.test.ts new file mode 100644 index 0000000..7374266 --- /dev/null +++ b/frontend/src/__tests__/updater-store.test.ts @@ -0,0 +1,172 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/services/api', () => ({ + api: { + checkForUpdate: vi.fn(), + openUpdateDownload: vi.fn(), + ensureAuthenticated: vi.fn(), + listAuthDevices: vi.fn(), + }, +})) + +import { api } from '@/services/api' +import { useAuthStore } from '@/stores/auth' +import { useUpdaterStore } from '@/stores/updater' + +const DISMISSED_STORAGE_KEY = 'futrix.updater.dismissedVersion' + +const baseResult = { + current: '1.0.17', + latest: '', + hasUpdate: false, + downloadUrl: '', + platformKey: 'macos-arm64', + platformLabel: 'macOS (Apple Silicon)', + releaseNotesUrl: 'https://futrixdata.com/#download', + authenticated: true, + lastCheckedAt: 1_700_000_000, +} + +beforeEach(() => { + // Other test files (e.g. ai-chat-store, app-i18n) replace localStorage on + // globalThis with a non-Storage stub. Reinstall a Map-backed Storage here so + // updater persistence is exercised reliably regardless of file order. + const data = new Map() + const storage: Storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value) }, + removeItem: (key: string) => { data.delete(key) }, + clear: () => { data.clear() }, + key: (index: number) => Array.from(data.keys())[index] ?? null, + get length() { return data.size }, + } + vi.stubGlobal('localStorage', storage) + Object.defineProperty(window, 'localStorage', { value: storage, configurable: true }) + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('updater store', () => { + it('reports an available update when latest is newer and authenticated', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + latest: '1.0.18', + hasUpdate: true, + downloadUrl: 'https://futrixdata.com/api/download/macos-arm64', + }) + const store = useUpdaterStore() + await store.check() + expect(store.hasUpdate).toBe(true) + expect(store.canOpenDownload).toBe(true) + expect(store.error).toBe('') + }) + + it('reports up-to-date when latest equals current', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + latest: '1.0.17', + hasUpdate: false, + }) + const store = useUpdaterStore() + await store.check() + expect(store.hasUpdate).toBe(false) + expect(store.result.lastCheckedAt).toBe(1_700_000_000) + }) + + it('treats unauthenticated response as no update without surfacing an error', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + authenticated: false, + latest: '1.0.18', + hasUpdate: false, + }) + const store = useUpdaterStore() + await store.check() + expect(store.hasUpdate).toBe(false) + expect(store.error).toBe('') + expect(store.result.authenticated).toBe(false) + }) + + it('captures network errors on the store error field', async () => { + ;(api as any).checkForUpdate.mockRejectedValue(new Error('network down')) + const store = useUpdaterStore() + await store.check() + expect(store.error).toBe('network down') + expect(store.hasUpdate).toBe(false) + }) + + it('re-syncs auth store when check returns authenticated=false while signed in', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + authenticated: false, + }) + ;(api as any).ensureAuthenticated.mockResolvedValue({ + deviceId: 'dev_1', + pendingLogin: null, + session: null, + }) + const auth = useAuthStore() + auth.state = { + deviceId: 'dev_1', + pendingLogin: null, + session: { accessToken: 't', refreshToken: 'r', expiresAt: 0, user: {}, license: {} }, + } as any + expect(auth.isAuthenticated).toBe(true) + const store = useUpdaterStore() + await store.check() + expect((api as any).ensureAuthenticated).toHaveBeenCalled() + expect(auth.isAuthenticated).toBe(false) + }) + + it('persists dismissal per version so the same release does not re-nag on restart', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + latest: '1.0.18', + hasUpdate: true, + downloadUrl: 'https://futrixdata.com/api/download/macos-arm64', + }) + const store = useUpdaterStore() + await store.check() + expect(store.dismissed).toBe(false) + store.dismiss() + expect(store.dismissed).toBe(true) + expect(window.localStorage.getItem(DISMISSED_STORAGE_KEY)).toBe('1.0.18') + + // Simulate restart: fresh store re-reads the persisted dismissed version. + setActivePinia(createPinia()) + const restarted = useUpdaterStore() + await restarted.check() + expect(restarted.dismissed).toBe(true) + }) + + it('un-dismisses automatically when a newer version becomes available', async () => { + window.localStorage.setItem(DISMISSED_STORAGE_KEY, '1.0.18') + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + latest: '1.0.19', + hasUpdate: true, + downloadUrl: 'https://futrixdata.com/api/download/macos-arm64', + }) + const store = useUpdaterStore() + await store.check() + expect(store.hasUpdate).toBe(true) + expect(store.dismissed).toBe(false) + }) + + it('opens the download URL through the API', async () => { + ;(api as any).checkForUpdate.mockResolvedValue({ + ...baseResult, + latest: '1.0.18', + hasUpdate: true, + downloadUrl: 'https://futrixdata.com/api/download/macos-arm64', + }) + ;(api as any).openUpdateDownload.mockResolvedValue(undefined) + const store = useUpdaterStore() + await store.check() + await store.openDownload() + expect((api as any).openUpdateDownload).toHaveBeenCalledWith( + 'https://futrixdata.com/api/download/macos-arm64', + ) + }) +}) diff --git a/frontend/src/__tests__/vega-lite-utils.test.ts b/frontend/src/__tests__/vega-lite-utils.test.ts new file mode 100644 index 0000000..128e0b1 --- /dev/null +++ b/frontend/src/__tests__/vega-lite-utils.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { enhanceVegaLiteSpec } from '@/utils/vegaLite' + +describe('enhanceVegaLiteSpec', () => { + it('adds schema and responsive sizing defaults', () => { + const input = { + data: { values: [{ name: 'A', value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'name', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + } + + const out = enhanceVegaLiteSpec(input) + + expect(out.$schema).toBe('https://vega.github.io/schema/vega-lite/v5.json') + expect(out.width).toBe('container') + expect(out.height).toBe('container') + expect(out.autosize).toEqual({ type: 'fit', contains: 'padding' }) + expect(out.config).toBeTruthy() + }) + + it('normalizes mark to object', () => { + const out = enhanceVegaLiteSpec({ + data: { values: [{ name: 'A', value: 1 }] }, + mark: 'line', + encoding: { + x: { field: 'name', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }) + + expect(typeof out.mark).toBe('object') + expect((out.mark as any).type).toBe('line') + }) +}) diff --git a/frontend/src/__tests__/virtual-table-first-visible.test.ts b/frontend/src/__tests__/virtual-table-first-visible.test.ts new file mode 100644 index 0000000..f071650 --- /dev/null +++ b/frontend/src/__tests__/virtual-table-first-visible.test.ts @@ -0,0 +1,93 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import VirtualTable from '@/components/VirtualTable.vue' + +vi.mock('@tanstack/vue-virtual', async () => { + const { ref } = await import('vue') + return { + useVirtualizer: () => + ref({ + getVirtualItems: () => [ + { index: 980, start: 9800, end: 9810, key: 980 }, + { index: 1000, start: 10000, end: 10010, key: 1000 }, + { index: 1001, start: 10010, end: 10020, key: 1001 }, + ], + getTotalSize: () => 20000, + scrollToIndex: vi.fn(), + }), + } +}) + +describe('VirtualTable first visible row', () => { + it('emits the first visible index based on scroll position', async () => { + const rows = Array.from({ length: 2005 }, (_, idx) => ({ id: idx })) + const wrapper = mount(VirtualTable, { + props: { + columns: ['id'], + rows, + rowHeight: 10, + }, + }) + + const container = wrapper.find('.virtual-table-container').element as HTMLElement + Object.defineProperty(container, 'scrollTop', { value: 10000, writable: true }) + Object.defineProperty(container, 'clientHeight', { value: 300, writable: true }) + Object.defineProperty(container, 'scrollHeight', { value: 20000, writable: true }) + + await wrapper.find('.virtual-table-container').trigger('scroll') + + const emitted = wrapper.emitted('update:firstVisibleIndex') || [] + const last = emitted[emitted.length - 1]?.[0] + expect(last).toBe(1000) + }) + + it('renders duplicate display names from ordered SQL column metadata', () => { + const rows = Array.from({ length: 2005 }, () => ({ id: '', id__2: '' })) + rows[980] = { id: 1, id__2: 9 } + const rowValues = Array.from({ length: 2005 }, () => ['', '']) + rowValues[980] = [1, 9] + + const wrapper = mount(VirtualTable, { + props: { + columns: ['id', 'id__2'], + rows, + columnMeta: [ + { key: 'id', name: 'id', position: 0 }, + { key: 'id__2', name: 'id', position: 1 }, + ], + rowValues, + }, + }) + + const headers = wrapper.findAll('thead th') + expect(headers.map((item) => item.text())).toEqual(['id', 'id']) + + const cells = wrapper.find('tbody tr[data-row-index="980"]').findAll('td') + expect(cells.map((item) => item.text())).toEqual(['1', '9']) + }) + + it('falls back to row maps when paged rows outgrow ordered SQL values', () => { + const rows = Array.from({ length: 2005 }, () => ({ id: '', id__2: '' })) + rows[980] = { id: 1, id__2: 9 } + rows[1000] = { id: 2, id__2: 10 } + rows[1001] = { id: 3, id__2: 11 } + const rowValues = [['only-first-page', 'only-first-page-2']] + + const wrapper = mount(VirtualTable, { + props: { + columns: ['id', 'id__2'], + rows, + columnMeta: [ + { key: 'id', name: 'id', position: 0 }, + { key: 'id__2', name: 'id', position: 1 }, + ], + rowValues, + }, + }) + + expect(wrapper.find('tbody tr[data-row-index="980"]').exists()).toBe(true) + const cells980 = wrapper.find('tbody tr[data-row-index="980"]').findAll('td') + expect(cells980.map((item) => item.text())).toEqual(['1', '9']) + }) +}) diff --git a/frontend/src/__tests__/virtual-table-row-actions.test.ts b/frontend/src/__tests__/virtual-table-row-actions.test.ts new file mode 100644 index 0000000..b07dc40 --- /dev/null +++ b/frontend/src/__tests__/virtual-table-row-actions.test.ts @@ -0,0 +1,98 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import VirtualTable from '@/components/VirtualTable.vue' + +const columns = ['id', 'name', 'age'] +const rows = [ + { id: 1, name: 'alice', age: 30 }, + { id: 2, name: 'bob', age: 25 }, +] + +describe('VirtualTable row actions + editable cells', () => { + it('does not render the row-actions column by default', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="result-row-delete"]').exists()).toBe(false) + }) + + it('renders a delete button per row when enableRowDelete is true', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows, enableRowDelete: true, rowDeleteLabel: 'Delete row' }, + }) + await flushPromises() + + const buttons = wrapper.findAll('[data-testid="result-row-delete"]') + expect(buttons.length).toBe(rows.length) + expect(buttons[0].attributes('title')).toBe('Delete row') + }) + + it('emits deleteRow with rowIndex and row payload when delete button clicked', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows, enableRowDelete: true }, + }) + await flushPromises() + + await wrapper.findAll('[data-testid="result-row-delete"]')[1].trigger('click') + + const events = wrapper.emitted('deleteRow') + expect(events?.length).toBe(1) + expect(events?.[0][0]).toMatchObject({ rowIndex: 1, row: { id: 2, name: 'bob', age: 25 } }) + }) + + it('emits editCell only for columns listed in editableColumns on dblclick', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows, editableColumns: ['name'] }, + }) + await flushPromises() + + const nameCell = wrapper.find('td[data-column-key="name"][data-row-index="0"]') + const idCell = wrapper.find('td[data-column-key="id"][data-row-index="0"]') + expect(nameCell.exists()).toBe(true) + expect(nameCell.classes()).toContain('result-cell-editable') + expect(idCell.classes()).not.toContain('result-cell-editable') + + await nameCell.trigger('dblclick') + await idCell.trigger('dblclick') + + const events = wrapper.emitted('editCell') + expect(events?.length).toBe(1) + expect(events?.[0][0]).toMatchObject({ rowIndex: 0, columnKey: 'name', currentValue: 'alice' }) + expect((events?.[0][0] as any).cellEl).toBeInstanceOf(HTMLTableCellElement) + }) + + it('renders the row-actions column as the first cell in each data row', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows, enableRowDelete: true, showRowIndex: true, showRowCopy: true }, + }) + await flushPromises() + + const headerCells = wrapper.findAll('thead th') + expect(headerCells[0].classes()).toContain('result-table-row-actions') + + const firstRow = wrapper.find('tbody tr[data-row-index="0"]') + const firstCell = firstRow.find(':scope > td') + expect(firstCell.classes()).toContain('result-table-row-actions') + expect(firstCell.find('[data-testid="result-row-delete"]').exists()).toBe(true) + }) + + it('expands header and spacer colspans when row-actions column is enabled', async () => { + const wrapper = mount(VirtualTable, { + attachTo: document.body, + props: { columns, rows: [], enableRowDelete: true }, + }) + await flushPromises() + + const emptyCell = wrapper.find('tr td.meta') + expect(emptyCell.exists()).toBe(true) + expect(Number(emptyCell.attributes('colspan'))).toBe(columns.length + 1) + }) +}) diff --git a/frontend/src/__tests__/visualization-back.test.ts b/frontend/src/__tests__/visualization-back.test.ts new file mode 100644 index 0000000..02af56e --- /dev/null +++ b/frontend/src/__tests__/visualization-back.test.ts @@ -0,0 +1,62 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import VisualizationView from '@/views/VisualizationView.vue' +import { useVisualizationStore } from '@/stores/visualization' + +const Dummy = { template: '
' } + +describe('VisualizationView back button', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + { path: '/visualization', name: 'visualization', component: Dummy }, + ], + }) + await router.push({ name: 'visualization' }) + await router.isReady() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('navigates back to the related console', async () => { + const viz = useVisualizationStore() + viz.setActive({ + renderer: 'echarts', + spec: {}, + datasourceId: 'ds_mysql', + }) + + const wrapper = mount(VisualizationView, { + global: { + plugins: [pinia, router], + stubs: { + EChartsRenderer: { template: '
' }, + }, + }, + }) + + await flushPromises() + + const back = wrapper.find('[data-testid="visualization-back"]') + expect(back.exists()).toBe(true) + + await back.trigger('click') + await flushPromises() + + expect(router.currentRoute.value.name).toBe('console') + expect(router.currentRoute.value.params.id).toBe('ds_mysql') + }) +}) diff --git a/frontend/src/__tests__/visualization-renderer-normalization.test.ts b/frontend/src/__tests__/visualization-renderer-normalization.test.ts new file mode 100644 index 0000000..a141885 --- /dev/null +++ b/frontend/src/__tests__/visualization-renderer-normalization.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +import { useVisualizationStore } from '@/stores/visualization' + +describe('Visualization renderer normalization', () => { + beforeEach(() => { + const pinia = createPinia() + setActivePinia(pinia) + }) + + it('normalizes vega-lite to vega_lite', () => { + const viz = useVisualizationStore() + viz.setActive({ renderer: 'vega-lite', spec: {} as any }) + expect(viz.active?.renderer).toBe('vega_lite') + }) +}) diff --git a/frontend/src/__tests__/visualization-vega-lite.test.ts b/frontend/src/__tests__/visualization-vega-lite.test.ts new file mode 100644 index 0000000..c7ea358 --- /dev/null +++ b/frontend/src/__tests__/visualization-vega-lite.test.ts @@ -0,0 +1,61 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' + +import VisualizationView from '@/views/VisualizationView.vue' +import { useVisualizationStore } from '@/stores/visualization' + +const Dummy = { template: '
' } + +describe('VisualizationView Vega-Lite renderer', () => { + let pinia: ReturnType + let router: ReturnType + + beforeEach(async () => { + pinia = createPinia() + setActivePinia(pinia) + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', name: 'datasources', component: Dummy }, + { path: '/console/:id', name: 'console', component: Dummy }, + { path: '/visualization', name: 'visualization', component: Dummy }, + ], + }) + await router.push({ name: 'visualization' }) + await router.isReady() + + const viz = useVisualizationStore() + viz.setActive({ + renderer: 'vega_lite', + spec: { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: [{ name: 'A', value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'name', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }, + }) + + }) + + it('renders VegaLiteRenderer when active.renderer is vega_lite', async () => { + const wrapper = mount(VisualizationView, { + global: { + plugins: [pinia, router], + stubs: { + EChartsRenderer: { template: '
' }, + ThreeRenderer: { template: '
' }, + VegaLiteRenderer: { template: '
' }, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('[data-testid="vega-lite-renderer-stub"]').exists()).toBe(true) + }) +}) diff --git a/frontend/src/__tests__/vite-config-wails-alias.test.ts b/frontend/src/__tests__/vite-config-wails-alias.test.ts new file mode 100644 index 0000000..670a138 --- /dev/null +++ b/frontend/src/__tests__/vite-config-wails-alias.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment node + +import fs from 'node:fs' +import path from 'node:path' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { resolveWailsAliasDir } from '../../vite.wails' + +const frontendRoot = path.resolve(__dirname, '../..') + +describe('vite @wailsjs alias', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the test stubs while running vitest', () => { + expect(resolveWailsAliasDir(frontendRoot, true)).toBe(path.resolve(frontendRoot, 'src/test/wailsjs')) + }) + + it('uses generated bindings outside vitest when they exist', () => { + vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => ( + String(filePath).includes(path.join('wailsjs', 'go', 'main', 'App.js')) + || String(filePath).includes(path.join('wailsjs', 'go', 'models.ts')) + || String(filePath).includes(path.join('wailsjs', 'runtime', 'runtime.js')) + )) + + expect(resolveWailsAliasDir(frontendRoot, false)).toBe(path.resolve(frontendRoot, 'wailsjs')) + }) + + it('falls back to test stubs outside vitest when generated bindings are missing', () => { + vi.spyOn(fs, 'existsSync').mockReturnValue(false) + + expect(resolveWailsAliasDir(frontendRoot, false)).toBe(path.resolve(frontendRoot, 'src/test/wailsjs')) + }) + + it('falls back to test stubs when the generated bindings directory is incomplete', () => { + vi.spyOn(fs, 'existsSync').mockImplementation((filePath) => ( + String(filePath).includes(path.join('wailsjs', 'go', 'main', 'App.js')) + )) + + expect(resolveWailsAliasDir(frontendRoot, false)).toBe(path.resolve(frontendRoot, 'src/test/wailsjs')) + }) +}) diff --git a/frontend/src/assets/fonts/OFL.txt b/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..5843e21 --- /dev/null +++ b/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/frontend/src/assets/fonts/nunito-v32-latin-variable.woff2 b/frontend/src/assets/fonts/nunito-v32-latin-variable.woff2 new file mode 100644 index 0000000..e118818 Binary files /dev/null and b/frontend/src/assets/fonts/nunito-v32-latin-variable.woff2 differ diff --git a/frontend/src/assets/svgs/chromadb.svg b/frontend/src/assets/svgs/chromadb.svg new file mode 100644 index 0000000..468d360 --- /dev/null +++ b/frontend/src/assets/svgs/chromadb.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/svgs/d1.svg b/frontend/src/assets/svgs/d1.svg new file mode 100644 index 0000000..d014bdc --- /dev/null +++ b/frontend/src/assets/svgs/d1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/dynamodb.svg b/frontend/src/assets/svgs/dynamodb.svg new file mode 100644 index 0000000..39f9762 --- /dev/null +++ b/frontend/src/assets/svgs/dynamodb.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/src/assets/svgs/elasticsearch.svg b/frontend/src/assets/svgs/elasticsearch.svg new file mode 100644 index 0000000..d5d30d0 --- /dev/null +++ b/frontend/src/assets/svgs/elasticsearch.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/assets/svgs/logo.png b/frontend/src/assets/svgs/logo.png new file mode 100644 index 0000000..9131708 Binary files /dev/null and b/frontend/src/assets/svgs/logo.png differ diff --git a/frontend/src/assets/svgs/logo.svg b/frontend/src/assets/svgs/logo.svg new file mode 100644 index 0000000..300cdbf --- /dev/null +++ b/frontend/src/assets/svgs/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/mongo.svg b/frontend/src/assets/svgs/mongo.svg new file mode 100644 index 0000000..a5d3707 --- /dev/null +++ b/frontend/src/assets/svgs/mongo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/moon.svg b/frontend/src/assets/svgs/moon.svg new file mode 100644 index 0000000..d9aa644 --- /dev/null +++ b/frontend/src/assets/svgs/moon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/svgs/mysql.svg b/frontend/src/assets/svgs/mysql.svg new file mode 100644 index 0000000..3c79415 --- /dev/null +++ b/frontend/src/assets/svgs/mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/nav-ai-settings.svg b/frontend/src/assets/svgs/nav-ai-settings.svg new file mode 100644 index 0000000..3da7fa7 --- /dev/null +++ b/frontend/src/assets/svgs/nav-ai-settings.svg @@ -0,0 +1,19 @@ + + + + + + + diff --git a/frontend/src/assets/svgs/nav-history.svg b/frontend/src/assets/svgs/nav-history.svg new file mode 100644 index 0000000..423cf65 --- /dev/null +++ b/frontend/src/assets/svgs/nav-history.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/assets/svgs/nav-my.svg b/frontend/src/assets/svgs/nav-my.svg new file mode 100644 index 0000000..176bc5c --- /dev/null +++ b/frontend/src/assets/svgs/nav-my.svg @@ -0,0 +1,15 @@ + + + + diff --git a/frontend/src/assets/svgs/nav-risk-rules.svg b/frontend/src/assets/svgs/nav-risk-rules.svg new file mode 100644 index 0000000..f5921b7 --- /dev/null +++ b/frontend/src/assets/svgs/nav-risk-rules.svg @@ -0,0 +1,14 @@ + + + + diff --git a/frontend/src/assets/svgs/nav-sensitivity.svg b/frontend/src/assets/svgs/nav-sensitivity.svg new file mode 100644 index 0000000..f402e60 --- /dev/null +++ b/frontend/src/assets/svgs/nav-sensitivity.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/assets/svgs/nav-sources.svg b/frontend/src/assets/svgs/nav-sources.svg new file mode 100644 index 0000000..d83c6cd --- /dev/null +++ b/frontend/src/assets/svgs/nav-sources.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/assets/svgs/nav-visualization.svg b/frontend/src/assets/svgs/nav-visualization.svg new file mode 100644 index 0000000..22c4603 --- /dev/null +++ b/frontend/src/assets/svgs/nav-visualization.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/assets/svgs/postgresql.svg b/frontend/src/assets/svgs/postgresql.svg new file mode 100644 index 0000000..4656cb7 --- /dev/null +++ b/frontend/src/assets/svgs/postgresql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/redis.svg b/frontend/src/assets/svgs/redis.svg new file mode 100644 index 0000000..7aabb0d --- /dev/null +++ b/frontend/src/assets/svgs/redis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/svgs/sun.svg b/frontend/src/assets/svgs/sun.svg new file mode 100644 index 0000000..0e9fc2c --- /dev/null +++ b/frontend/src/assets/svgs/sun.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/AIConfigForm.vue b/frontend/src/components/AIConfigForm.vue new file mode 100644 index 0000000..293a52c --- /dev/null +++ b/frontend/src/components/AIConfigForm.vue @@ -0,0 +1,340 @@ + + + diff --git a/frontend/src/components/AIConfigPanel.vue b/frontend/src/components/AIConfigPanel.vue new file mode 100644 index 0000000..aafc6d3 --- /dev/null +++ b/frontend/src/components/AIConfigPanel.vue @@ -0,0 +1,276 @@ + + + diff --git a/frontend/src/components/ConsoleMonacoEditor.vue b/frontend/src/components/ConsoleMonacoEditor.vue new file mode 100644 index 0000000..07cd399 --- /dev/null +++ b/frontend/src/components/ConsoleMonacoEditor.vue @@ -0,0 +1,804 @@ + + + diff --git a/frontend/src/components/ai/useAiSidebar.ts b/frontend/src/components/ai/useAiSidebar.ts new file mode 100644 index 0000000..ab41e7f --- /dev/null +++ b/frontend/src/components/ai/useAiSidebar.ts @@ -0,0 +1,977 @@ +import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import type { AiAgentDecision, AiAgentPlan, AiAgentPlanStep, AiContextChip } from '@/types/ai-chat' +import { useAiChatStore } from '@/stores/ai-chat' +import { useVisualizationStore } from '@/stores/visualization' +import { useAppStore } from '@/stores/app' +import { buildContextGroups } from '@/modules/ai/context' +import { tApp } from '@/modules/i18n/appI18n' +import { api } from '@/services/api' +import type { aichat } from '@wailsjs/go/models' +import { EventsOn } from '@wailsjs/runtime/runtime' + +type ApprovalTone = 'neutral' | 'safe' | 'warning' | 'danger' | 'caution' +type PlanViewMode = 'markdown' | 'workflow' + +const normalizeReason = (value: unknown) => String(value || '').trim().toUpperCase() + +const extractRiskReasons = (payload: any): string[] => { + const raw = payload?.risk?.reasons + if (!raw) return [] + if (Array.isArray(raw)) return raw.map(normalizeReason).filter(Boolean) + const normalized = normalizeReason(raw) + return normalized ? [normalized] : [] +} + +const extractExamined = (payload: any): number => { + const explain = payload?.explain + const docs = Number(explain?.totalDocsExamined || 0) + const keys = Number(explain?.totalKeysExamined || 0) + return Math.max(Number.isFinite(docs) ? docs : 0, Number.isFinite(keys) ? keys : 0) +} + +const firstKeyword = (statement: unknown): string => { + const text = String(statement || '').trim() + if (!text) return '' + const match = /^[a-zA-Z_]+/.exec(text) + return (match?.[0] || '').toLowerCase() +} + +const isDeleteOperationReason = (reason: string) => + reason === 'DELETE' + || reason === 'DELETEONE' + || reason === 'DELETEMANY' + || reason === 'DEL' + || reason === 'HDEL' + || reason === 'SREM' + || reason === 'ZREM' + +const isAddOperationReason = (reason: string) => + reason === 'INSERT/REPLACE' + || reason === 'INSERTONE' + || reason === 'INSERTMANY' + || reason === 'SADD' + || reason === 'ZADD' + || reason === 'HSET' + || reason === 'SET' + || reason === 'MSET' + +const normalizeAgentDecision = (raw: unknown): AiAgentDecision | undefined => { + const value = raw as any + if (!value || typeof value !== 'object') return undefined + const mode = String(value.mode || '').trim() + const complexity = String(value.complexity || '').trim() + const reason = String(value.reason || '').trim() + const confidenceRaw = Number(value.confidence) + const confidence = Number.isFinite(confidenceRaw) ? confidenceRaw : undefined + if (!mode && !complexity && !reason && confidence === undefined) return undefined + return { + mode: mode || undefined, + complexity: complexity || undefined, + reason: reason || undefined, + confidence, + } +} + +const normalizeAgentPlanStep = (raw: unknown): AiAgentPlanStep | undefined => { + const value = raw as any + if (!value || typeof value !== 'object') return undefined + const id = String(value.id || '').trim() + const title = String(value.title || '').trim() + const description = String(value.description || '').trim() + const status = String(value.status || '').trim() + if (!id && !title && !description && !status) return undefined + return { + id: id || undefined, + title: title || undefined, + description: description || undefined, + status: status || undefined, + } +} + +const normalizeAgentPlan = (raw: unknown): AiAgentPlan | undefined => { + const value = raw as any + if (!value || typeof value !== 'object') return undefined + const title = String(value.title || '').trim() + const summary = String(value.summary || '').trim() + const markdown = String(value.markdown || '').trim() + const steps = Array.isArray(value.steps) + ? value.steps.map(normalizeAgentPlanStep).filter((step): step is AiAgentPlanStep => Boolean(step)) + : [] + if (!title && !summary && !markdown && !steps.length) return undefined + return { + title: title || undefined, + summary: summary || undefined, + markdown: markdown || undefined, + steps, + } +} + +const extractTurnMetadata = (resp: unknown): { agent?: AiAgentDecision; plan?: AiAgentPlan } => { + const value = resp as any + return { + agent: normalizeAgentDecision(value?.agent), + plan: normalizeAgentPlan(value?.plan), + } +} + +export function useAiSidebar() { + const instance = getCurrentInstance() + const store = useAiChatStore() + const visualizationStore = useVisualizationStore() + const appStore = useAppStore() + + const draft = computed({ + get: () => store.draft, + set: (v) => store.setDraft(v), + }) + const contextChips = ref([]) + const contextQuery = ref('') + const showContext = ref(false) + const planViewByMessageId = ref>({}) + + const selectedProviderId = ref('') + const isApproving = ref(false) + const isBusy = computed(() => Boolean(store.inFlight)) + const isSending = computed(() => isBusy.value || isApproving.value) + + const activeContextIndex = ref(0) + + const modelOpen = ref(false) + const modelActiveIndex = ref(0) + const modelSelectRef = ref(null) + const modelMenuId = 'ai-model-menu' + const modelMenuPlacement = ref<'down' | 'up'>('down') + + const getPlanView = (messageId: string): PlanViewMode => { + const current = planViewByMessageId.value[messageId] + return current === 'workflow' ? 'workflow' : 'markdown' + } + + const setPlanView = (messageId: string, view: PlanViewMode) => { + if (!messageId) return + planViewByMessageId.value = { ...planViewByMessageId.value, [messageId]: view } + } + + const resolveAgentModeLabel = (mode: string | undefined): string => { + const normalized = String(mode || '').trim().toLowerCase() + if (normalized === 'plan_executor') return tApp('ai.sidebar.agent.planExecutor') + if (normalized === 'deepagent') return tApp('ai.sidebar.agent.deepagent') + if (normalized === 'chatmodel' || normalized === 'chat_model') return tApp('ai.sidebar.agent.chatmodel') + return tApp('ai.sidebar.agent.unknown') + } + + const resolvePlanStepStatusLabel = (status: string | undefined): string => { + const normalized = String(status || '').trim().toLowerCase() + if (normalized === 'completed' || normalized === 'done') return tApp('ai.sidebar.plan.status.completed') + if (normalized === 'in_progress' || normalized === 'running') return tApp('ai.sidebar.plan.status.inProgress') + if (normalized === 'blocked') return tApp('ai.sidebar.plan.status.blocked') + return tApp('ai.sidebar.plan.status.pending') + } + + const buildPlanMarkdown = (plan: AiAgentPlan | undefined): string => { + if (!plan) return '' + const direct = String(plan.markdown || '').trim() + if (direct) return direct + + const lines: string[] = [] + const title = String(plan.title || '').trim() + const summary = String(plan.summary || '').trim() + if (title) lines.push(`### ${title}`) + if (summary) lines.push(summary) + + const steps = Array.isArray(plan.steps) ? plan.steps : [] + if (steps.length) { + if (lines.length) lines.push('') + for (let index = 0; index < steps.length; index += 1) { + const step = steps[index] + const label = String(step.title || '').trim() || tApp('ai.sidebar.plan.stepDefault', { index: index + 1 }) + const status = resolvePlanStepStatusLabel(step.status) + lines.push(`${index + 1}. ${label} (${status})`) + const description = String(step.description || '').trim() + if (description) lines.push(` - ${description}`) + } + } + return lines.join('\n').trim() + } + + const activeMessages = computed(() => (store.activeId ? store.messagesById[store.activeId] || [] : [])) + const activeApproval = computed(() => { + const id = store.activeId + if (!id) return null + return store.pendingApprovalByConversationId[id] || null + }) + const approvalTone = computed(() => { + const approval = activeApproval.value + if (!approval) return 'neutral' + + const kind = String(approval.kind || '') + if (kind === 'analyze_result' || kind === 'create_visualization') return 'warning' + if (kind === 'delete_datasource') return 'danger' + if (kind === 'create_datasource') return 'warning' + if (kind !== 'execute_statement') return 'neutral' + + const payload = approval.payload || {} + const riskLevel = String(payload?.risk?.level || '').trim().toLowerCase() + const reasons = extractRiskReasons(payload) + + if (reasons.some(isDeleteOperationReason)) return 'danger' + if (riskLevel === 'high') return 'danger' + + if (reasons.some(isAddOperationReason)) return 'warning' + if (riskLevel === 'medium') return 'warning' + + // Fallback for dev/mock payloads that don't include risk. + const keyword = firstKeyword(payload?.statement) + if (keyword === 'delete' || keyword === 'drop' || keyword === 'truncate') return 'danger' + if (keyword === 'insert' || keyword === 'replace' || keyword === 'update' || keyword === 'alter' || keyword === 'create') return 'warning' + + // Keep read operations green; use brown only for > 1000 examined. + const examined = extractExamined(payload) + if (examined > 1000) return 'caution' + + return 'safe' + }) + const approvalToneClass = computed(() => `ai-approval-tone-${approvalTone.value}`) + + const providerOptions = computed(() => + appStore.aiConfigs + .filter((cfg) => String(cfg.status || '').toLowerCase() === 'connected') + .map((cfg) => { + const providerName = String(cfg.name || cfg.provider || tApp('ai.sidebar.providerFallback')) + const modelName = String(cfg.model || '') + return { id: String(cfg.id ?? cfg.provider ?? cfg.name ?? ''), label: modelName ? `${modelName} · ${providerName}` : providerName } + }), + ) + + const selectedProviderLabel = computed(() => { + if (!providerOptions.value.length) return tApp('ai.sidebar.noProvider') + const current = providerOptions.value.find((opt) => opt.id === selectedProviderId.value) + return current?.label || providerOptions.value[0]?.label || tApp('ai.sidebar.noProvider') + }) + + const contextGroups = computed(() => { + const current = appStore.current + const currentDatabase = current?.type === 'mongodb' ? appStore.mongoDatabase || current.database || '' : current?.database || '' + return buildContextGroups({ + datasources: appStore.datasources.map((ds) => ({ id: ds.id, name: ds.name, type: ds.type })), + currentDatasourceId: current?.id, + currentDatabase, + currentEntity: appStore.selectedEntity || '', + }) + }) + + const filteredGroups = computed(() => { + const query = contextQuery.value.trim().toLowerCase() + if (!query) return contextGroups.value + return contextGroups.value + .map((group) => ({ ...group, items: group.items.filter((item) => item.label.toLowerCase().includes(query)) })) + .filter((group) => group.items.length) + }) + + const flattenedContextItems = computed(() => filteredGroups.value.flatMap((group) => group.items)) + + const contextIndexMap = computed(() => { + const map = new Map() + flattenedContextItems.value.forEach((item, index) => map.set(item.id, index)) + return map + }) + + // Composer logic + const composerInputRef = ref(null) + + const handleNewChat = () => { + if (!store.activeId) return + const existing = store.messagesById[store.activeId] || [] + if (!existing.length) return + store.clearActive() + } + + const resizeComposerInput = () => { + const el = composerInputRef.value + if (!el) return + + el.style.height = 'auto' + const maxHeight = Number.parseFloat(window.getComputedStyle(el).maxHeight || '') + if (Number.isFinite(maxHeight) && maxHeight > 0) { + const nextHeight = Math.min(el.scrollHeight, maxHeight) + el.style.height = `${nextHeight}px` + el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden' + return + } + + el.style.height = `${el.scrollHeight}px` + } + + const handleInput = () => { + resizeComposerInput() + + const match = /@([^\\s]*)$/.exec(draft.value) + if (match) { + contextQuery.value = match[1] || '' + showContext.value = true + activeContextIndex.value = 0 + return + } + showContext.value = false + contextQuery.value = '' + } + + const closeModelMenu = () => { modelOpen.value = false } + + const updateModelPlacement = () => { + const selectEl = modelSelectRef.value + if (!selectEl) return + const sidebar = selectEl.closest('.ai-sidebar') as HTMLElement | null + if (!sidebar) return + const selectRect = selectEl.getBoundingClientRect() + const sidebarRect = sidebar.getBoundingClientRect() + const spaceBelow = sidebarRect.bottom - selectRect.bottom + const spaceAbove = selectRect.top - sidebarRect.top + const menuHeight = 200 + modelMenuPlacement.value = spaceBelow < menuHeight && spaceAbove > spaceBelow ? 'up' : 'down' + } + + const openModelMenu = () => { + if (!providerOptions.value.length) return + updateModelPlacement() + modelOpen.value = true + const currentIndex = providerOptions.value.findIndex((opt) => opt.id === selectedProviderId.value) + modelActiveIndex.value = currentIndex >= 0 ? currentIndex : 0 + } + + const toggleModelMenu = () => { modelOpen.value ? closeModelMenu() : openModelMenu() } + const selectModel = (id: string) => { selectedProviderId.value = id; closeModelMenu() } + + const handleModelKeydown = (event: KeyboardEvent) => { + if (!providerOptions.value.length) return + if (!modelOpen.value && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openModelMenu(); return } + if (!modelOpen.value) return + + if (event.key === 'ArrowDown') { event.preventDefault(); modelActiveIndex.value = Math.min(modelActiveIndex.value + 1, providerOptions.value.length - 1); return } + if (event.key === 'ArrowUp') { event.preventDefault(); modelActiveIndex.value = Math.max(modelActiveIndex.value - 1, 0); return } + if (event.key === 'Enter') { + event.preventDefault() + const selected = providerOptions.value[modelActiveIndex.value] + if (selected) selectModel(selected.id) + return + } + if (event.key === 'Escape') { event.preventDefault(); closeModelMenu() } + } + + const selectContext = (chip: AiContextChip) => { + if (!contextChips.value.find((item) => item.id === chip.id)) { + contextChips.value = [...contextChips.value, chip] + } + showContext.value = false + contextQuery.value = '' + } + + const removeContext = (id: string) => { contextChips.value = contextChips.value.filter((chip) => chip.id !== id) } + + const handleComposerKeydown = (event: KeyboardEvent) => { + // When using an IME (e.g. Chinese), Enter may confirm composition rather than sending. + const isImeComposing = Boolean((event as any).isComposing) || (event as any).keyCode === 229 + if (event.key === 'Enter' && isImeComposing) { + return + } + if (showContext.value && flattenedContextItems.value.length) { + if (event.key === 'ArrowDown') { event.preventDefault(); activeContextIndex.value = Math.min(activeContextIndex.value + 1, flattenedContextItems.value.length - 1); return } + if (event.key === 'ArrowUp') { event.preventDefault(); activeContextIndex.value = Math.max(activeContextIndex.value - 1, 0); return } + if (event.key === 'Enter') { + event.preventDefault() + const selected = flattenedContextItems.value[activeContextIndex.value] + if (selected) selectContext(selected) + return + } + if (event.key === 'Escape') { event.preventDefault(); showContext.value = false; contextQuery.value = ''; return } + } + if (event.key === 'Enter') { + if (event.shiftKey) return + event.preventDefault() + send() + } + } + + const resolveRoute = () => { + const route = (instance?.appContext.config.globalProperties as any)?.$route + return { name: route?.name, path: route?.path } + } + + const resolveRouter = () => (instance?.appContext.config.globalProperties as any)?.$router + + const hasWailsRuntime = () => + typeof window !== 'undefined' && Boolean((window as { runtime?: unknown }).runtime) + + const isAiContextDebugEnabled = () => { + if (typeof window === 'undefined') return false + try { + return window.localStorage?.getItem('fd.debug.aiContext') === '1' + } catch { + return false + } + } + + const previewAiContextText = (value: string, max = 200) => { + const text = String(value || '') + if (!text) return '' + return text.length > max ? `${text.slice(0, max)}…` : text + } + + const debugAiContext = (event: string, payload: Record) => { + if (!isAiContextDebugEnabled()) return + console.info(`[fd][ai-context] ${event}`, payload) + } + + const buildPageContext = ( + implicitStatement = '', + pendingPageContext: null | { + currentDatasourceId?: string + currentDatasourceType?: string + currentDatabase?: string + currentEntity?: string + currentStatement?: string + } = null, + ): aichat.PageContext => { + const current = appStore.current + const currentDatabase = current?.type === 'mongodb' ? appStore.mongoDatabase || current.database || '' : current?.database || '' + const route = resolveRoute() + const override = pendingPageContext || null + const statement = String(override?.currentStatement || implicitStatement || '').trim() + return { + routeName: String(route?.name ?? ''), + routePath: String(route?.path ?? ''), + currentDatasourceId: String(override?.currentDatasourceId || current?.id || ''), + currentDatasourceType: String(override?.currentDatasourceType || current?.type || ''), + currentDatabase: String(override?.currentDatabase ?? currentDatabase), + currentEntity: String(override?.currentEntity ?? appStore.selectedEntity ?? ''), + datasourceStatuses: appStore.datasources.map((ds) => ({ + id: ds.id, + status: String(appStore.status[ds.id] || 'unknown'), + checkedAt: Number(appStore.statusCheckedAt[ds.id] || 0), + detail: String(appStore.statusDetails[ds.id] || ''), + })), + currentStatement: statement, + lastConsoleError: String(appStore.lastConsoleError ?? ''), + } + } + + const toContextChipsPayload = (chips: AiContextChip[]): aichat.ContextChip[] => + chips.map((chip) => ({ + id: chip.id, + label: chip.label, + kind: chip.kind, + datasourceId: chip.datasourceId, + })) + + const withImplicitStatementContext = (content: string, implicitStatement?: string) => { + const statement = String(implicitStatement || '').trim() + if (!statement) return String(content || '') + return `${String(content || '')}\n\n[implicit_statement]\n${statement}` + } + + const toMessagesPayload = (id: string): aichat.Message[] => { + const msgs = store.messagesById[id] || [] + return msgs.slice(-20).map((msg) => ({ + role: msg.role, + content: withImplicitStatementContext(msg.content, msg.implicitStatement), + })) + } + + const navigateTo = (path: string) => { + const target = String(path || '').trim() + if (!target) return + const router = resolveRouter() + if (router && typeof router.push === 'function') { + router.push(target) + return + } + if (typeof window !== 'undefined') { + window.location.hash = `#${target.startsWith('/') ? target : `/${target}`}` + } + } + + const typeOut = async (turnId: string, conversationId: string, messageId: string, text: string) => { + const value = String(text || '') + store.setAssistantContent(conversationId, messageId, '') + if (!value) return + const total = value.length + const chunkSize = total > 1200 ? 24 : total > 600 ? 16 : 10 + const delayMs = total > 1200 ? 12 : total > 600 ? 16 : 22 + + let index = 0 + // eslint-disable-next-line no-constant-condition + while (true) { + if (store.inFlight?.turnId !== turnId) return + const chunk = value.slice(index, index + chunkSize) + if (!chunk) break + store.appendAssistantDelta(conversationId, messageId, chunk) + index += chunk.length + await new Promise((r) => window.setTimeout(r, delayMs)) + } + } + + const unsubs: Array<() => void> = [] + const ignoredStreamIds = new Set() + + const ignoreStream = (streamId: string) => { + const id = String(streamId || '').trim() + if (!id) return + ignoredStreamIds.add(id) + if (ignoredStreamIds.size <= 32) return + const oldest = ignoredStreamIds.values().next() + if (!oldest.done) ignoredStreamIds.delete(oldest.value) + } + + const bindCurrentStream = (payload: any) => { + const streamId = String(payload?.streamId || '').trim() + if (streamId && ignoredStreamIds.has(streamId)) return null + + const conversationId = String(payload?.conversationId || '') + const current = store.inFlight + if (!current) return null + + if (current.streamId) { + if (current.streamId !== streamId) return null + return current + } + + if (!streamId || conversationId !== current.conversationId) return null + ignoredStreamIds.delete(streamId) + store.setInFlightStreamId(current.turnId, streamId) + return store.inFlight || current + } + + const removeAssistantPlaceholderIfEmpty = (conversationId: string, messageId: string) => { + const msgs = store.messagesById[conversationId] || [] + const msg = msgs.find((item) => item.id === messageId) + if (!msg || msg.role !== 'assistant') return + if (String(msg.content || '').trim() !== '') return + store.removeMessage(conversationId, messageId) + } + + const cancelInFlight = async () => { + const current = store.inFlight + if (!current) return + const { turnId, conversationId, assistantMessageId, streamId } = current + + removeAssistantPlaceholderIfEmpty(conversationId, assistantMessageId) + store.clearInFlight(turnId) + + if (!hasWailsRuntime()) return + + if (streamId) { + ignoreStream(streamId) + try { + await api.aiChatCancelStream(streamId) + } catch { + // ignore + } + return + } + + store.setCancelPendingTurnId(turnId) + } + + const send = async () => { + const text = draft.value.trim() + if (!text) return + if (isBusy.value) return + if (activeApproval.value) { + store.addAssistantMessage(tApp('ai.sidebar.pendingApprovalFirst')) + return + } + + const implicitStatement = String(store.pendingContext || '') + const pendingPageContext = store.pendingPageContext + ? { ...(store.pendingPageContext as Record) } + : null + debugAiContext('ai-sidebar-send-start', { + hasRuntime: hasWailsRuntime(), + textLength: text.length, + textPreview: previewAiContextText(text, 120), + implicitStatementLength: implicitStatement.length, + implicitStatementPreview: previewAiContextText(implicitStatement), + contextChipCount: contextChips.value.length, + activeConversationId: String(store.activeId || ''), + }) + store.sendMessage(text, contextChips.value, implicitStatement) + store.setPendingContext(null) + store.setPendingPageContext(null) + draft.value = '' + nextTick(() => resizeComposerInput()) + showContext.value = false + contextQuery.value = '' + + const conversationId = store.activeId + if (!conversationId) return + + const messagesPayload = toMessagesPayload(conversationId) + + let turnId: string | null = null + let assistantMessageId: string | null = null + + try { + const assistantMsg = store.startAssistantMessage(conversationId) + assistantMessageId = assistantMsg?.id || null + if (!assistantMessageId) return + turnId = assistantMessageId + store.setInFlight({ turnId, conversationId, assistantMessageId, createdAt: Date.now() }) + + const payload = { + aiConfigId: selectedProviderId.value || '', + conversationId, + messages: messagesPayload, + contextChips: toContextChipsPayload(contextChips.value), + implicitStatement, + pageContext: buildPageContext(implicitStatement, pendingPageContext as any), + } + debugAiContext('ai-sidebar-send-payload', { + hasRuntime: hasWailsRuntime(), + conversationId, + messageCount: messagesPayload.length, + lastMessagePreview: previewAiContextText(String(messagesPayload[messagesPayload.length - 1]?.content || ''), 240), + implicitStatementLength: implicitStatement.length, + implicitStatementPreview: previewAiContextText(implicitStatement), + }) + + if (hasWailsRuntime()) { + const start = await api.aiChatTurnStream(payload as any) + const streamId = String((start as any).streamId || '') + if (!streamId) { + store.setAssistantContent(conversationId, assistantMessageId, tApp('ai.sidebar.requestFailed')) + store.clearInFlight(turnId) + return + } + if (store.cancelPendingTurnId === turnId || store.inFlight?.turnId !== turnId) { + store.setCancelPendingTurnId(null) + ignoreStream(streamId) + try { + await api.aiChatCancelStream(streamId) + } catch { + // ignore + } + return + } + ignoredStreamIds.delete(streamId) + store.setInFlightStreamId(turnId, streamId) + return + } + + const resp = await api.aiChatTurn(payload as any) + if (!turnId || store.inFlight?.turnId !== turnId) return + const assistantText = String(resp.assistantMessage || '') + await typeOut(turnId, conversationId, assistantMessageId, assistantText) + if (store.inFlight?.turnId !== turnId) return + + const metadata = extractTurnMetadata(resp) + store.setAssistantMetadata(conversationId, assistantMessageId, metadata) + + if (resp.approval) { + store.setPendingApproval(conversationId, resp.approval as any) + } + if (resp.effects?.consoleResult) { + store.setConsoleResult(resp.effects.consoleResult as any) + const route = resolveRoute() + const currentId = String(appStore.current?.id || '') + const targetId = String((resp.effects.consoleResult as any)?.datasourceId || '') + if (targetId && (route?.name !== 'console' || currentId !== targetId)) { + navigateTo(`/console/${targetId}`) + } + } + if (resp.effects?.datasourcesChanged) { + await appStore.loadDatasources() + } + if (resp.effects?.visualization) { + visualizationStore.setActive(resp.effects.visualization as any) + if (!resp.effects?.navigateTo) navigateTo('/visualization') + } + if (resp.effects?.navigateTo) { + navigateTo(String(resp.effects.navigateTo)) + } + } catch (err) { + if (turnId && assistantMessageId && store.inFlight?.turnId === turnId) { + store.setAssistantContent(conversationId, assistantMessageId, err instanceof Error ? err.message : tApp('ai.sidebar.requestFailed')) + store.clearInFlight(turnId) + } + } finally { + if (!hasWailsRuntime() && turnId) store.clearInFlight(turnId) + } + } + + const respondToApproval = async (decision: 'approve' | 'reject') => { + const conversationId = store.activeId + const approval = activeApproval.value + if (!conversationId || !approval) return + if (isApproving.value) return + + isApproving.value = true + try { + const resp = await api.aiChatApprove({ + conversationId, + approvalId: approval.id, + decision, + }) + store.clearPendingApproval(conversationId) + const assistantText = String(resp.assistantMessage || '').trim() + if (assistantText) { + const meta = extractTurnMetadata(resp) + store.addAssistantMessage(assistantText, meta) + } + if (resp.approval) { + store.setPendingApproval(conversationId, resp.approval as any) + if (!assistantText && resp.approval.summary) { + store.addAssistantMessage(String(resp.approval.summary)) + } + } + if (resp.effects?.consoleResult) { + store.setConsoleResult(resp.effects.consoleResult as any) + const route = resolveRoute() + const currentId = String(appStore.current?.id || '') + const targetId = String((resp.effects.consoleResult as any)?.datasourceId || '') + if (targetId && (route?.name !== 'console' || currentId !== targetId)) { + navigateTo(`/console/${targetId}`) + } + } + if (resp.effects?.datasourcesChanged) { + await appStore.loadDatasources() + } + if (resp.effects?.visualization) { + visualizationStore.setActive(resp.effects.visualization as any) + if (!resp.effects?.navigateTo) navigateTo('/visualization') + } + if (resp.effects?.navigateTo) { + navigateTo(String(resp.effects.navigateTo)) + } + } catch (err) { + store.addAssistantMessage(err instanceof Error ? err.message : tApp('ai.sidebar.approvalFailed')) + } finally { + isApproving.value = false + } + } + + watch(providerOptions, (options) => { + if (!options.length) { selectedProviderId.value = ''; modelOpen.value = false; return } + const connected = appStore.aiConfigs.find((cfg) => String(cfg.status).toLowerCase() === 'connected') + const fallback = connected?.id ? String(connected.id) : options[0].id + if (!selectedProviderId.value || !options.find((opt) => opt.id === selectedProviderId.value)) { + selectedProviderId.value = fallback + } + }, { immediate: true }) + + watch(() => store.autoSend, (shouldSend) => { + if (shouldSend && draft.value.trim() && !isBusy.value) { + send() + store.setAutoSend(false) + } + }) + + watch([flattenedContextItems, showContext], ([items, open]) => { + if (!open) { activeContextIndex.value = 0; return } + if (activeContextIndex.value >= items.length) activeContextIndex.value = 0 + }) + + watch(draft, () => { + nextTick(() => resizeComposerInput()) + }) + + const handleDocumentMouseDown = (event: MouseEvent) => { + if (!modelOpen.value) return + const target = event.target as Node | null + if (!target || !modelSelectRef.value) return + if (!modelSelectRef.value.contains(target)) closeModelMenu() + } + + onMounted(() => { + document.addEventListener('mousedown', handleDocumentMouseDown) + nextTick(() => resizeComposerInput()) + + if (!hasWailsRuntime()) return + + unsubs.push(EventsOn('aichat:progress', (payload: any) => { + const current = bindCurrentStream(payload) + if (!current) return + const message = String(payload?.message || '') + store.applyInFlightProgress(current.turnId, message) + })) + + unsubs.push(EventsOn('aichat:delta', (payload: any) => { + const current = bindCurrentStream(payload) + if (!current) return + const delta = String(payload?.delta || '') + if (!delta) return + store.appendAssistantDelta(current.conversationId, current.assistantMessageId, delta) + })) + + unsubs.push(EventsOn('aichat:error', (payload: any) => { + const current = bindCurrentStream(payload) + if (!current) return + const message = String(payload?.error || tApp('ai.sidebar.requestFailed')) + store.setAssistantContent(current.conversationId, current.assistantMessageId, message) + store.clearInFlight(current.turnId) + })) + + unsubs.push(EventsOn('aichat:done', async (payload: any) => { + const current = bindCurrentStream(payload) + if (!current) return + + const resp = payload?.response as any + const finalText = String(resp?.assistantMessage || '') + const metadata = extractTurnMetadata(resp) + let metadataMessageId = current.assistantMessageId + + if (finalText.trim()) { + const existing = store.messagesById[current.conversationId] || [] + const msg = existing.find((item) => item.id === current.assistantMessageId) + const currentText = msg?.role === 'assistant' ? String(msg.content || '') : '' + const currentTrim = currentText.trim() + const placeholderTrim = String(current.progressPlaceholder || '').trim() + const effectiveCurrentTrim = placeholderTrim && currentTrim === placeholderTrim ? '' : currentTrim + const finalTrim = finalText.trim() + + const compact = (value: string) => value.replace(/\s+/g, '') + const currentCompact = compact(effectiveCurrentTrim) + const finalCompact = compact(finalTrim) + + const isEquivalent = finalTrim === effectiveCurrentTrim + || (currentCompact && finalCompact && finalCompact === currentCompact) + const isFinalExtension = Boolean(effectiveCurrentTrim) + && (finalTrim.startsWith(effectiveCurrentTrim) + || (currentCompact && finalCompact && finalCompact.startsWith(currentCompact))) + const isFinalTruncated = Boolean(effectiveCurrentTrim) + && (effectiveCurrentTrim.startsWith(finalTrim) + || (currentCompact && finalCompact && currentCompact.startsWith(finalCompact))) + + const commonPrefixLen = (a: string, b: string) => { + const max = Math.min(a.length, b.length) + let i = 0 + while (i < max && a.charCodeAt(i) === b.charCodeAt(i)) i++ + return i + } + const commonSuffixLen = (a: string, b: string, prefixLen: number) => { + const aEnd = a.length - 1 + const bEnd = b.length - 1 + let i = aEnd + let j = bEnd + let count = 0 + while (i >= prefixLen && j >= prefixLen && a.charCodeAt(i) === b.charCodeAt(j)) { + count++ + i-- + j-- + } + return count + } + const isHighlySimilar = (a: string, b: string) => { + if (!a || !b) return false + const maxLen = Math.max(a.length, b.length) + if (!maxLen) return false + const prefixLen = commonPrefixLen(a, b) + const suffixLen = commonSuffixLen(a, b, prefixLen) + const common = Math.min(prefixLen+suffixLen, Math.min(a.length, b.length)) + const coverage = common / maxLen + return coverage >= 0.95 + } + const isRepairRewrite = Boolean(effectiveCurrentTrim) + && !isEquivalent + && !isFinalExtension + && !isFinalTruncated + && isHighlySimilar(currentCompact, finalCompact) + + if (!effectiveCurrentTrim) { + store.setAssistantContent(current.conversationId, current.assistantMessageId, finalText) + } else if (isFinalExtension || isRepairRewrite) { + store.setAssistantContent(current.conversationId, current.assistantMessageId, finalText) + } else if (!isEquivalent && !isFinalExtension && !isFinalTruncated) { + const extra = store.startAssistantMessage(current.conversationId) + if (extra?.id) { + store.setAssistantContent(current.conversationId, extra.id, finalText) + metadataMessageId = extra.id + } + } + } + + store.setAssistantMetadata(current.conversationId, metadataMessageId, metadata) + + if (resp?.approval) { + store.setPendingApproval(current.conversationId, resp.approval) + } + if (resp?.effects?.datasourcesChanged) { + await appStore.loadDatasources() + } + if (resp?.effects?.consoleResult) { + store.setConsoleResult(resp.effects.consoleResult as any) + const route = resolveRoute() + const currentId = String(appStore.current?.id || '') + const targetId = String((resp.effects.consoleResult as any)?.datasourceId || '') + if (targetId && (route?.name !== 'console' || currentId !== targetId)) { + navigateTo(`/console/${targetId}`) + } + } + if (resp?.effects?.visualization) { + visualizationStore.setActive(resp.effects.visualization as any) + if (!resp.effects?.navigateTo) navigateTo('/visualization') + } + if (resp?.effects?.navigateTo) { + navigateTo(String(resp.effects.navigateTo)) + } + store.clearInFlight(current.turnId) + })) + + unsubs.push(EventsOn('aichat:cancelled', (payload: any) => { + const current = bindCurrentStream(payload) + if (!current) return + removeAssistantPlaceholderIfEmpty(current.conversationId, current.assistantMessageId) + store.clearInFlight(current.turnId) + })) + }) + + onBeforeUnmount(() => { + document.removeEventListener('mousedown', handleDocumentMouseDown) + unsubs.splice(0).forEach((fn) => { + try { fn() } catch { /* ignore */ } + }) + }) + + return { + store, + draft, + composerInputRef, + contextChips, + contextQuery, + showContext, + selectedProviderId, + isSending, + isApproving, + activeApproval, + approvalToneClass, + activeContextIndex, + modelOpen, + modelActiveIndex, + modelSelectRef, + modelMenuId, + modelMenuPlacement, + activeMessages, + planViewByMessageId, + providerOptions, + selectedProviderLabel, + filteredGroups, + flattenedContextItems, + contextIndexMap, + getPlanView, + setPlanView, + resolveAgentModeLabel, + resolvePlanStepStatusLabel, + buildPlanMarkdown, + handleNewChat, + handleInput, + handleComposerKeydown, + toggleModelMenu, + handleModelKeydown, + selectModel, + selectContext, + removeContext, + send, + cancelInFlight, + respondToApproval, + isBusy, + } +} diff --git a/frontend/src/components/auth/AuthGate.vue b/frontend/src/components/auth/AuthGate.vue new file mode 100644 index 0000000..567a235 --- /dev/null +++ b/frontend/src/components/auth/AuthGate.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/components/consoleMonacoCompletion.ts b/frontend/src/components/consoleMonacoCompletion.ts new file mode 100644 index 0000000..c69e70a --- /dev/null +++ b/frontend/src/components/consoleMonacoCompletion.ts @@ -0,0 +1,113 @@ +import type { DescribeResult } from '@/types' +import { + getAutocompleteSuggestions, + resolveAutocompleteInsertValue, + type AutocompleteItem, +} from '@/views/console/composables/autocomplete/suggestions' + +export type MonacoCompletionPayloadItem = { + label: string + insertText: string + detail?: string + type: AutocompleteItem['type'] +} + +export type MonacoCompletionPayload = { + items: MonacoCompletionPayloadItem[] + insertStart: number + insertEnd: number + title: string + prefix: string +} + +type BuildPayloadParams = { + statement: string + cursorOffset: number + datasourceType?: string + entities: string[] + entityDetail: DescribeResult | null + entityDetailsMap?: Record + activeEntity?: string +} + +const LETTER_TRIGGER_CHARACTERS = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +export const SQL_TRIGGER_CHARACTERS = [ + ' ', + '.', + '"', + '`', + '/', + '_', + ...LETTER_TRIGGER_CHARACTERS, +] + +export const MONGO_TRIGGER_CHARACTERS = [ + ' ', + '.', + '"', + "'", + '[', + '$', + '{', + '_', + ...LETTER_TRIGGER_CHARACTERS, +] + +const normalizeDatasourceType = (value: string | undefined) => { + return String(value || '').trim().toLowerCase() +} + +export const buildMonacoCompletionPayload = ({ + statement, + cursorOffset, + datasourceType, + entities, + entityDetail, + entityDetailsMap, + activeEntity, +}: BuildPayloadParams): MonacoCompletionPayload => { + const text = String(statement || '') + const safeCursor = Math.max(0, Math.min(cursorOffset, text.length)) + const type = normalizeDatasourceType(datasourceType) + const isMongo = type === 'mongodb' + const isElastic = type === 'elasticsearch' + const isSQL = type === 'mysql' || type === 'postgresql' || type === 'd1' + + const suggestion = getAutocompleteSuggestions({ + text, + cursorPos: safeCursor, + entities: Array.isArray(entities) ? entities : [], + entityDetail, + entityDetailsMap, + isMongo, + isElastic, + isSQL, + datasourceType: type, + activeEntity: String(activeEntity || ''), + }) + + if (!suggestion) { + return { + items: [], + insertStart: safeCursor, + insertEnd: safeCursor, + title: '', + prefix: '', + } + } + + return { + items: suggestion.items.map((item) => ({ + label: item.label, + insertText: resolveAutocompleteInsertValue(item), + detail: item.hint, + type: item.type, + })), + insertStart: suggestion.insertStart, + insertEnd: suggestion.insertEnd, + title: suggestion.title, + prefix: suggestion.prefix, + } +} diff --git a/frontend/src/components/consoleMonacoContextMenu.ts b/frontend/src/components/consoleMonacoContextMenu.ts new file mode 100644 index 0000000..3a6eb37 --- /dev/null +++ b/frontend/src/components/consoleMonacoContextMenu.ts @@ -0,0 +1,54 @@ +export type OffsetRange = { + start: number + end: number +} + +const clampInt = (value: number, min: number, max: number) => { + if (!Number.isFinite(value)) return min + return Math.max(min, Math.min(max, Math.round(value))) +} + +export const normalizeRange = (range: OffsetRange, textLength: number): OffsetRange => { + const max = Math.max(0, Math.floor(Number.isFinite(textLength) ? textLength : 0)) + const start = clampInt(range.start, 0, max) + const end = clampInt(range.end, start, max) + return { start, end } +} + +export const resolveContextSelection = (args: { + textLength: number + currentSelection: OffsetRange + contextOffset: number + lastNonEmptySelection: OffsetRange | null + allowLastSelectionFallback?: boolean +}): OffsetRange => { + const textLength = Math.max(0, Math.floor(Number.isFinite(args.textLength) ? args.textLength : 0)) + const current = normalizeRange(args.currentSelection, textLength) + if (current.start !== current.end) return current + + const offset = clampInt(args.contextOffset, 0, textLength) + if (!args.allowLastSelectionFallback) return { start: offset, end: offset } + const fallback = args.lastNonEmptySelection ? normalizeRange(args.lastNonEmptySelection, textLength) : null + if (!fallback) return { start: offset, end: offset } + if (fallback.start === fallback.end) return { start: offset, end: offset } + return fallback +} + +export const resolveContextOffset = (args: { + textLength: number + contextOffset?: number | null + mouseDownOffset?: number | null + selectionOffset?: number | null +}) => { + const textLength = Math.max(0, Math.floor(Number.isFinite(args.textLength) ? args.textLength : 0)) + if (Number.isFinite(args.contextOffset)) { + return clampInt(Number(args.contextOffset), 0, textLength) + } + if (Number.isFinite(args.mouseDownOffset)) { + return clampInt(Number(args.mouseDownOffset), 0, textLength) + } + if (Number.isFinite(args.selectionOffset)) { + return clampInt(Number(args.selectionOffset), 0, textLength) + } + return 0 +} diff --git a/frontend/src/components/consoleMonacoEnvironment.ts b/frontend/src/components/consoleMonacoEnvironment.ts new file mode 100644 index 0000000..f2cc1f4 --- /dev/null +++ b/frontend/src/components/consoleMonacoEnvironment.ts @@ -0,0 +1,44 @@ +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' +import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' +import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker' +import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker' +import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' + +type MonacoWorkerConstructor = new () => Worker + +type MonacoEnvironmentHost = typeof globalThis & { + MonacoEnvironment?: { + getWorker?: (moduleId: string, label: string) => Worker + } + __futrixMonacoEnvironmentReady?: boolean +} + +export const monacoWorkerKindForLabel = (label: string) => { + const normalized = String(label || '').toLowerCase() + if (normalized === 'json') return 'json' + if (normalized === 'css' || normalized === 'scss' || normalized === 'less') return 'css' + if (normalized === 'html' || normalized === 'handlebars' || normalized === 'razor') return 'html' + if (normalized === 'typescript' || normalized === 'javascript') return 'typescript' + return 'editor' +} + +const workerForKind = (kind: ReturnType): MonacoWorkerConstructor => { + if (kind === 'json') return JsonWorker + if (kind === 'css') return CssWorker + if (kind === 'html') return HtmlWorker + if (kind === 'typescript') return TsWorker + return EditorWorker +} + +export const ensureMonacoEnvironment = () => { + const host = globalThis as MonacoEnvironmentHost + if (host.__futrixMonacoEnvironmentReady) return + host.MonacoEnvironment = { + ...host.MonacoEnvironment, + getWorker(_moduleId: string, label: string) { + const WorkerCtor = workerForKind(monacoWorkerKindForLabel(label)) + return new WorkerCtor() + }, + } + host.__futrixMonacoEnvironmentReady = true +} diff --git a/frontend/src/components/redis-protobuf/ProtobufManageDialog.vue b/frontend/src/components/redis-protobuf/ProtobufManageDialog.vue new file mode 100644 index 0000000..39ff085 --- /dev/null +++ b/frontend/src/components/redis-protobuf/ProtobufManageDialog.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/frontend/src/components/redis-protobuf/SearchableSelect.vue b/frontend/src/components/redis-protobuf/SearchableSelect.vue new file mode 100644 index 0000000..4e9093a --- /dev/null +++ b/frontend/src/components/redis-protobuf/SearchableSelect.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/components/redis/RedisTypeBadge.vue b/frontend/src/components/redis/RedisTypeBadge.vue new file mode 100644 index 0000000..096e03b --- /dev/null +++ b/frontend/src/components/redis/RedisTypeBadge.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/components/riskRules/TrustLevelPanel.vue b/frontend/src/components/riskRules/TrustLevelPanel.vue new file mode 100644 index 0000000..4bc9063 --- /dev/null +++ b/frontend/src/components/riskRules/TrustLevelPanel.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/frontend/src/components/sensitivity/SchemaPrivacyPanel.vue b/frontend/src/components/sensitivity/SchemaPrivacyPanel.vue new file mode 100644 index 0000000..96c9ac3 --- /dev/null +++ b/frontend/src/components/sensitivity/SchemaPrivacyPanel.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/frontend/src/components/skill/ManualInstallDialog.vue b/frontend/src/components/skill/ManualInstallDialog.vue new file mode 100644 index 0000000..5c48bf2 --- /dev/null +++ b/frontend/src/components/skill/ManualInstallDialog.vue @@ -0,0 +1,545 @@ + + + + + diff --git a/frontend/src/components/skill/NewManualAgentDialog.vue b/frontend/src/components/skill/NewManualAgentDialog.vue new file mode 100644 index 0000000..f34263e --- /dev/null +++ b/frontend/src/components/skill/NewManualAgentDialog.vue @@ -0,0 +1,769 @@ + + + + + diff --git a/frontend/src/components/skill/SkillInstallDialog.vue b/frontend/src/components/skill/SkillInstallDialog.vue new file mode 100644 index 0000000..3aab50a --- /dev/null +++ b/frontend/src/components/skill/SkillInstallDialog.vue @@ -0,0 +1,660 @@ + + + + + diff --git a/frontend/src/components/startup/StartupRecoveryView.vue b/frontend/src/components/startup/StartupRecoveryView.vue new file mode 100644 index 0000000..0c867dd --- /dev/null +++ b/frontend/src/components/startup/StartupRecoveryView.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/frontend/src/components/useAIConfigPanel.ts b/frontend/src/components/useAIConfigPanel.ts new file mode 100644 index 0000000..57febf8 --- /dev/null +++ b/frontend/src/components/useAIConfigPanel.ts @@ -0,0 +1,170 @@ +import { computed, onBeforeUnmount, onMounted, ref } from 'vue' +import { api } from '@/services/api' +import { useAppStore } from '@/stores/app' +import type { AIConfig } from '@/types' +import { tApp } from '@/modules/i18n/appI18n' + +type Props = { visible: boolean; inline?: boolean; split?: boolean } + +export function useAIConfigPanel(props: Props, emit: any) { + const store = useAppStore() + + const inline = computed(() => Boolean(props.inline)) + const split = computed(() => Boolean(props.split)) + const configs = computed(() => store.aiConfigs) + const actionMenuId = ref(null) + const expandedDetails = ref>({}) + + const normalizedStatus = (status: string) => String(status || '').toLowerCase() + const isConnected = (status: string) => ['connected', 'success', 'ok', 'testing'].includes(normalizedStatus(status)) + + const connectedConfigs = computed(() => configs.value.filter((cfg) => isConnected(cfg.status))) + const failedConfigs = computed(() => configs.value.filter((cfg) => !isConnected(cfg.status))) + const sortedConfigs = computed(() => [...connectedConfigs.value, ...failedConfigs.value]) + + const statusLabel = (status: string) => (isConnected(status) ? tApp('status.connected') : tApp('status.failed')) + const statusClass = (status: string) => (isConnected(status) ? 'connected' : 'failed') + + const statusDetail = (cfg: AIConfig) => { + const normalized = normalizedStatus(cfg.status) + if (normalized === 'testing') return tApp('status.testingEllipsis') + if (isConnected(cfg.status)) { + const parts = [] + if (cfg.lastModelInfo) parts.push(cfg.lastModelInfo) + if (cfg.lastLatencyMs) parts.push(`${cfg.lastLatencyMs}ms`) + return parts.join(' · ') + } + return cfg.statusDetail || '' + } + + const shouldToggleDetail = (cfg: AIConfig) => statusDetail(cfg).length > 120 + const isExpanded = (id: string) => Boolean(expandedDetails.value[id]) + const toggleDetail = (id: string) => { expandedDetails.value = { ...expandedDetails.value, [id]: !expandedDetails.value[id] } } + + const isActionMenuOpen = (id: string) => actionMenuId.value === id + const toggleActionMenu = (id: string) => { actionMenuId.value = actionMenuId.value === id ? null : id } + + const openEdit = (id: string) => { actionMenuId.value = null; emit('edit', id) } + + const deleteConfirmOpen = ref(false) + const deleteConfirmBusy = ref(false) + const deleteTarget = ref(null) + + const requestDelete = (cfg: AIConfig) => { + actionMenuId.value = null + deleteTarget.value = cfg + deleteConfirmOpen.value = true + } + + const closeDeleteConfirm = () => { + if (deleteConfirmBusy.value) return + deleteConfirmOpen.value = false + } + + const confirmDelete = async () => { + const cfg = deleteTarget.value + if (!cfg) return + if (deleteConfirmBusy.value) return + + deleteConfirmBusy.value = true + try { + await api.deleteAIConfig(cfg.id) + await store.loadAIConfigs() + store.setNotice(tApp('ai.panel.deleted')) + } catch (err) { + store.setNotice(err instanceof Error ? err.message : String(err), 'error') + } finally { + deleteConfirmBusy.value = false + deleteConfirmOpen.value = false + deleteTarget.value = null + } + } + + const openDelete = (cfg: AIConfig) => { void requestDelete(cfg) } + + const onWindowClick = (event: MouseEvent) => { + const target = event.target as HTMLElement | null + if (!target) return + if (target.closest('.ai-action-menu') || target.closest('.ai-action-toggle')) return + actionMenuId.value = null + } + + const testConfig = async (cfg: AIConfig) => { + try { + cfg.status = 'testing' + const result = await api.testAIConfig(cfg.id) + cfg.status = result.connected ? 'connected' : 'failed' + cfg.statusDetail = result.connected ? '' : result.error + cfg.lastLatencyMs = result.latencyMs + cfg.lastModelInfo = result.modelInfo + } catch (err) { + cfg.status = 'failed' + cfg.statusDetail = err instanceof Error ? err.message : String(err) + } + await store.loadAIConfigs() + } + + const resizeState = ref({ active: false, startX: 0, startWidth: 0 }) + + const startResize = (event: MouseEvent) => { + resizeState.value.active = true + resizeState.value.startX = event.clientX + const panel = (event.currentTarget as HTMLElement)?.parentElement as HTMLElement | null + resizeState.value.startWidth = panel?.getBoundingClientRect().width || 0 + document.body.classList.add('resizing') + } + + const onMouseMove = (event: MouseEvent) => { + if (!resizeState.value.active) return + const panel = document.getElementById('ai-config-panel') + if (!panel) return + const delta = resizeState.value.startX - event.clientX + const width = Math.min(Math.max(resizeState.value.startWidth + delta, 320), 820) + panel.style.width = `${width}px` + } + + const onMouseUp = () => { + if (!resizeState.value.active) return + resizeState.value.active = false + document.body.classList.remove('resizing') + } + + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + onBeforeUnmount(() => { + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + window.removeEventListener('click', onWindowClick) + }) + + onMounted(() => { + window.addEventListener('click', onWindowClick) + }) + + return { + inline, + split, + configs, + connectedConfigs, + failedConfigs, + sortedConfigs, + statusLabel, + statusClass, + statusDetail, + shouldToggleDetail, + isExpanded, + toggleDetail, + isActionMenuOpen, + toggleActionMenu, + openEdit, + openDelete, + deleteConfirmOpen, + deleteConfirmBusy, + deleteTarget, + closeDeleteConfirm, + confirmDelete, + testConfig, + startResize, + } +} diff --git a/frontend/src/components/visualization/EChartsRenderer.vue b/frontend/src/components/visualization/EChartsRenderer.vue new file mode 100644 index 0000000..6a57373 --- /dev/null +++ b/frontend/src/components/visualization/EChartsRenderer.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/components/visualization/ThreeRenderer.vue b/frontend/src/components/visualization/ThreeRenderer.vue new file mode 100644 index 0000000..af74a59 --- /dev/null +++ b/frontend/src/components/visualization/ThreeRenderer.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/components/visualization/VegaLiteRenderer.vue b/frontend/src/components/visualization/VegaLiteRenderer.vue new file mode 100644 index 0000000..cc9e76a --- /dev/null +++ b/frontend/src/components/visualization/VegaLiteRenderer.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/core/layout/MainLayout.vue b/frontend/src/core/layout/MainLayout.vue new file mode 100644 index 0000000..194ebed --- /dev/null +++ b/frontend/src/core/layout/MainLayout.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/frontend/src/core/layout/Sidebar.vue b/frontend/src/core/layout/Sidebar.vue new file mode 100644 index 0000000..78b2adc --- /dev/null +++ b/frontend/src/core/layout/Sidebar.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9435e76 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import { api } from '@/services/api' +import { initAppI18n } from '@/modules/i18n/appI18n' +import { installClientErrorLogging } from '@/modules/logging/clientErrors' +import './style.css' + +initAppI18n() +installClientErrorLogging((kind, message, detail) => api.recordClientError(kind, message, detail)) + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/modules/ai/context.ts b/frontend/src/modules/ai/context.ts new file mode 100644 index 0000000..048c5df --- /dev/null +++ b/frontend/src/modules/ai/context.ts @@ -0,0 +1,72 @@ +import type { AiContextChip } from '@/types/ai-chat' +import type { DataSource } from '@/types' +import { tApp } from '@/modules/i18n/appI18n' + +type BuildContextInput = { + datasources: Array> + currentDatasourceId?: string + currentDatabase?: string + currentEntity?: string +} + +type ContextGroup = { title: string; items: AiContextChip[] } + +const mockEntityFor = (type: string) => { + if (type === 'mongodb') return ['events', 'profiles', 'logs'] + if (type === 'redis') return ['session:*', 'cache:*', 'queue:*'] + return ['users', 'orders', 'payments'] +} + +export const buildContextGroups = (input: BuildContextInput): ContextGroup[] => { + const currentItems: AiContextChip[] = [] + if (input.currentDatabase) { + currentItems.push({ + id: `db:${input.currentDatabase}`, + label: input.currentDatabase, + kind: 'database', + datasourceId: input.currentDatasourceId, + }) + } + if (input.currentEntity) { + currentItems.push({ + id: `entity:${input.currentEntity}`, + label: input.currentEntity, + kind: 'table', + datasourceId: input.currentDatasourceId, + }) + } + + const restInDatasource: AiContextChip[] = [] + const otherDatasource: AiContextChip[] = [] + + input.datasources.forEach((ds) => { + const items = mockEntityFor(ds.type) + items.forEach((name) => { + const chip: AiContextChip = { + id: `${ds.id}:${name}`, + label: `${ds.name}/${name}`, + kind: ds.type === 'mongodb' ? 'collection' : 'table', + datasourceId: ds.id, + } + if (ds.id === input.currentDatasourceId) { + restInDatasource.push(chip) + } else { + otherDatasource.push(chip) + } + }) + }) + + const byLabel = (a: AiContextChip, b: AiContextChip) => a.label.localeCompare(b.label) + restInDatasource.sort(byLabel) + otherDatasource.sort(byLabel) + + const groups: ContextGroup[] = [] + if (currentItems.length) groups.push({ title: tApp('ai.contextGroup.current'), items: currentItems }) + if (restInDatasource.length) { + groups.push({ title: tApp('ai.contextGroup.otherInDatasource'), items: restInDatasource }) + } + if (otherDatasource.length) groups.push({ title: tApp('ai.contextGroup.otherDatasources'), items: otherDatasource }) + return groups +} + +export type { BuildContextInput, ContextGroup } diff --git a/frontend/src/modules/datasource/icons.test.ts b/frontend/src/modules/datasource/icons.test.ts new file mode 100644 index 0000000..4b03991 --- /dev/null +++ b/frontend/src/modules/datasource/icons.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { getDatasourceTypeIconUrl } from './icons' + +describe('datasource icons', () => { + it('maps known types to svg asset urls', () => { + expect(getDatasourceTypeIconUrl('mysql')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('postgresql')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('mongodb')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('redis')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('elasticsearch')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('dynamodb')).toEqual(expect.any(String)) + expect(getDatasourceTypeIconUrl('d1')).toEqual(expect.any(String)) + }) + + it('normalizes redis cluster variants', () => { + expect(getDatasourceTypeIconUrl('redis_cluster')).toBe(getDatasourceTypeIconUrl('redis')) + expect(getDatasourceTypeIconUrl('redis-cluster')).toBe(getDatasourceTypeIconUrl('redis')) + }) + + it('returns null for unknown types', () => { + expect(getDatasourceTypeIconUrl('')).toBeNull() + expect(getDatasourceTypeIconUrl('unknown')).toBeNull() + }) +}) diff --git a/frontend/src/modules/datasource/icons.ts b/frontend/src/modules/datasource/icons.ts new file mode 100644 index 0000000..52003fd --- /dev/null +++ b/frontend/src/modules/datasource/icons.ts @@ -0,0 +1,35 @@ +import chromadbIconUrl from '@/assets/svgs/chromadb.svg' +import d1IconUrl from '@/assets/svgs/d1.svg' +import dynamodbIconUrl from '@/assets/svgs/dynamodb.svg' +import elasticsearchIconUrl from '@/assets/svgs/elasticsearch.svg' +import mongoIconUrl from '@/assets/svgs/mongo.svg' +import mysqlIconUrl from '@/assets/svgs/mysql.svg' +import postgresqlIconUrl from '@/assets/svgs/postgresql.svg' +import redisIconUrl from '@/assets/svgs/redis.svg' + +const ICONS: Record = { + mysql: mysqlIconUrl, + postgresql: postgresqlIconUrl, + mongodb: mongoIconUrl, + redis: redisIconUrl, + elasticsearch: elasticsearchIconUrl, + dynamodb: dynamodbIconUrl, + d1: d1IconUrl, + chromadb: chromadbIconUrl, +} + +const normalizeIconType = (value: string) => { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return '' + + const slug = normalized.replace(/[^a-z0-9]+/g, '_') + if (slug === 'redis_cluster') return 'redis' + if (slug === 'mongo') return 'mongodb' + if (slug === 'cloudflare_d1') return 'd1' + return slug +} + +export const getDatasourceTypeIconUrl = (type: string): string | null => { + const normalized = normalizeIconType(type) + return ICONS[normalized] || null +} diff --git a/frontend/src/modules/datasource/types.test.ts b/frontend/src/modules/datasource/types.test.ts new file mode 100644 index 0000000..0ce59e6 --- /dev/null +++ b/frontend/src/modules/datasource/types.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { dataSourceTypeOptions, formatDatasourceTypeLabel, normalizeDatasourceType } from './types' + +describe('datasource types', () => { + it('does not include redis_cluster option', () => { + const values = dataSourceTypeOptions.map((item) => item.value) + expect(values).not.toContain('redis_cluster') + }) + + it('formats redis label', () => { + expect(formatDatasourceTypeLabel('redis')).toBe('Redis') + }) + + it('includes d1 option and label', () => { + const values = dataSourceTypeOptions.map((item) => item.value) + expect(values).toContain('d1') + expect(formatDatasourceTypeLabel('d1')).toBe('Cloudflare D1') + }) + + it('includes chromadb option and label', () => { + const values = dataSourceTypeOptions.map((item) => item.value) + expect(values).toContain('chromadb') + expect(formatDatasourceTypeLabel('chromadb')).toBe('ChromaDB') + }) + + it('normalizes redis_cluster to redis', () => { + expect(normalizeDatasourceType('redis_cluster')).toBe('redis') + }) +}) diff --git a/frontend/src/modules/datasource/types.ts b/frontend/src/modules/datasource/types.ts new file mode 100644 index 0000000..b7ceb48 --- /dev/null +++ b/frontend/src/modules/datasource/types.ts @@ -0,0 +1,43 @@ +import type { DataSourceType } from '@/types' +import { tApp } from '@/modules/i18n/appI18n' + +export type DataSourceTypeOption = { + value: DataSourceType + label: string +} + +export const dataSourceTypeOptions: DataSourceTypeOption[] = [ + { value: 'mysql', label: 'MySQL' }, + { value: 'postgresql', label: 'PostgreSQL' }, + { value: 'mongodb', label: 'MongoDB' }, + { value: 'redis', label: 'Redis' }, + { value: 'elasticsearch', label: 'Elasticsearch' }, + { value: 'dynamodb', label: tApp('datasource.type.dynamodb') }, + { value: 'd1', label: tApp('datasource.type.d1') }, + { value: 'chromadb', label: tApp('datasource.type.chromadb') }, +] + +export const normalizeDatasourceType = (type: string) => (type === 'redis_cluster' ? 'redis' : type) + +export const formatDatasourceTypeLabel = (type: string) => { + switch (type) { + case 'mysql': + return 'MySQL' + case 'postgresql': + return 'PostgreSQL' + case 'mongodb': + return 'MongoDB' + case 'redis': + return 'Redis' + case 'elasticsearch': + return 'Elasticsearch' + case 'dynamodb': + return tApp('datasource.type.dynamodb') + case 'd1': + return tApp('datasource.type.d1') + case 'chromadb': + return tApp('datasource.type.chromadb') + default: + return tApp('datasource.type.unknown') + } +} diff --git a/frontend/src/modules/dynamodb/credentials.ts b/frontend/src/modules/dynamodb/credentials.ts new file mode 100644 index 0000000..e6b8b45 --- /dev/null +++ b/frontend/src/modules/dynamodb/credentials.ts @@ -0,0 +1,136 @@ +export type AwsStaticCredentials = { + accessKeyId: string + secretAccessKey: string + sessionToken?: string +} + +type ProfileBlock = { accessKeyId?: string; secretAccessKey?: string; sessionToken?: string } + +const normalizeAwsKey = (key: string) => key.trim().toLowerCase().replace(/[\s-]+/g, '_') + +const stripQuotes = (value: string) => value.replace(/^\"|\"$/g, '').replace(/^'|'$/g, '') + +export function parseAwsCredentials(text: string, preferredProfile?: string): { profile: string; credentials: AwsStaticCredentials } { + const trimmed = String(text || '').trim() + if (!trimmed) throw new Error('Credentials file is empty.') + + const profiles = parseAwsSharedCredentials(trimmed) + if (Object.keys(profiles).length) { + const preferred = String(preferredProfile || '').trim() + const selectedProfile = preferred && profiles[preferred] + ? preferred + : profiles.default + ? 'default' + : Object.keys(profiles)[0] + + const block = profiles[selectedProfile] + const accessKeyId = String(block?.accessKeyId || '').trim() + const secretAccessKey = String(block?.secretAccessKey || '').trim() + const sessionToken = String(block?.sessionToken || '').trim() + if (!accessKeyId || !secretAccessKey) throw new Error('Selected profile is missing access key id or secret access key.') + return { + profile: selectedProfile, + credentials: { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }, + } + } + + const csvCreds = parseAwsAccessKeysCsv(trimmed) + if (csvCreds) { + return { profile: '', credentials: csvCreds } + } + + throw new Error('Unrecognized credentials format. Use AWS shared credentials file (~/.aws/credentials) or IAM access keys CSV.') +} + +export function parseAwsSharedCredentials(text: string): Record { + const lines = String(text || '').split(/\r?\n/) + const out: Record = {} + let current = '' + + const ensure = (profile: string) => { + if (!out[profile]) out[profile] = {} + return out[profile] + } + + for (const rawLine of lines) { + const line = rawLine.trim() + if (!line) continue + if (line.startsWith('#') || line.startsWith(';')) continue + if (line.startsWith('[') && line.endsWith(']')) { + current = line.slice(1, -1).trim() + continue + } + if (!current) continue + + const idx = line.indexOf('=') + if (idx === -1) continue + const key = normalizeAwsKey(line.slice(0, idx)) + const value = stripQuotes(line.slice(idx + 1).trim()) + const block = ensure(current) + if (key === 'aws_access_key_id') block.accessKeyId = value + else if (key === 'aws_secret_access_key') block.secretAccessKey = value + else if (key === 'aws_session_token') block.sessionToken = value + } + for (const key of Object.keys(out)) { + const block = out[key] + if (!block?.accessKeyId || !block?.secretAccessKey) delete out[key] + } + return out +} + +export function parseAwsAccessKeysCsv(text: string): AwsStaticCredentials | null { + const lines = String(text || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean) + if (lines.length < 2) return null + const header = parseCsvLine(lines[0] || '') + const row = parseCsvLine(lines[1] || '') + if (header.length < 2 || row.length < 2) return null + + const columnIndex = (want: string) => header.findIndex((h) => normalizeAwsKey(h) === normalizeAwsKey(want)) + const accessIdx = columnIndex('access_key_id') + const secretIdx = columnIndex('secret_access_key') + if (accessIdx === -1 || secretIdx === -1) { + // IAM console CSV uses labels like "Access key ID","Secret access key" + const accessAlt = header.findIndex((h) => /access\s*key\s*id/i.test(h)) + const secretAlt = header.findIndex((h) => /secret\s*access\s*key/i.test(h)) + if (accessAlt === -1 || secretAlt === -1) return null + const accessKeyId = String(row[accessAlt] || '').trim() + const secretAccessKey = String(row[secretAlt] || '').trim() + if (!accessKeyId || !secretAccessKey) return null + return { accessKeyId, secretAccessKey } + } + + const accessKeyId = String(row[accessIdx] || '').trim() + const secretAccessKey = String(row[secretIdx] || '').trim() + if (!accessKeyId || !secretAccessKey) return null + return { accessKeyId, secretAccessKey } +} + +function parseCsvLine(line: string): string[] { + const out: string[] = [] + let cur = '' + let inQuotes = false + for (let i = 0; i < line.length; i += 1) { + const ch = line[i] || '' + if (ch === '"') { + if (inQuotes && line[i + 1] === '"') { + cur += '"' + i += 1 + continue + } + inQuotes = !inQuotes + continue + } + if (ch === ',' && !inQuotes) { + out.push(cur.trim()) + cur = '' + continue + } + cur += ch + } + out.push(cur.trim()) + return out.map((val) => stripQuotes(val)) +} diff --git a/frontend/src/modules/i18n/appI18n.ts b/frontend/src/modules/i18n/appI18n.ts new file mode 100644 index 0000000..31c9459 --- /dev/null +++ b/frontend/src/modules/i18n/appI18n.ts @@ -0,0 +1,4610 @@ +import { readonly, ref } from 'vue' + +export type AppLocale = 'en' | 'zh' | 'ja' | 'es' | 'de' + +type MessageParams = Record + +const LOCALE_STORAGE_KEY = 'futrix.app.locale' + +const APP_CONSOLE_MESSAGES: Record<'en' | 'zh', Record> = { + en: { + 'common.listSeparator': ', ', + 'common.metricSeparator': ', ', + + 'status.failedWithMessage': 'Failed | {message}', + 'status.skippedEmpty': 'Skipped | empty', + 'status.skippedWithMessage': 'Skipped | {message}', + 'status.safetyNoIndex': 'Safety check | no index', + 'status.running': 'Running...', + 'status.explainUsesIndex': 'Explain | uses index', + 'status.explainNoIndex': 'Explain | no index', + 'status.explainNotSupported': 'Explain is not supported for this datasource.', + 'status.success': 'Success', + + 'validation.sql.limitBeforeOrderBy': + 'SQL syntax: ORDER BY must come before LIMIT. Example: SELECT ... ORDER BY id DESC LIMIT 50;', + 'validation.mongo.invalidStatement': 'Invalid Mongo statement.', + 'validation.mongo.selectDatabase': 'Select a database to run Mongo statements.', + + 'console.precheck.unclosedSingleQuote': 'Unclosed string literal — single quote was never closed.', + 'console.precheck.unclosedDoubleQuote': 'Unclosed identifier — double quote was never closed.', + 'console.precheck.unclosedBacktick': 'Unclosed identifier — backtick was never closed.', + 'console.precheck.unclosedDollarQuote': 'Unclosed PostgreSQL dollar-quoted string — missing closing "$tag$".', + 'console.precheck.unclosedBlockComment': 'Unclosed block comment — missing closing "*/".', + 'console.precheck.unbalancedParenOpen': 'Unbalanced parenthesis — opening "(" was never closed.', + 'console.precheck.unbalancedParenClose': 'Unbalanced parenthesis — extra closing ")" with no matching "(".', + 'console.precheck.danglingComma': 'Dangling comma — remove the trailing comma or add the missing expression.', + 'console.precheck.fix.closeSingleQuote': "Close with '", + 'console.precheck.fix.closeDoubleQuote': 'Close with "', + 'console.precheck.fix.closeBacktick': 'Close with `', + 'console.precheck.fix.closeDollarQuote': 'Close the dollar-quoted string', + 'console.precheck.fix.closeBlockComment': 'Close with */', + 'console.precheck.fix.closeParen': 'Close with )', + 'console.precheck.fix.removeComma': 'Remove the trailing comma', + 'console.precheck.applyFix': 'Apply suggested fix', + 'console.precheck.heading': 'Suggested fixes before running', + + 'console.error.mysql.syntaxNear': 'SQL syntax error near `{snippet}`.', + 'console.error.mysql.unknownColumn': 'Unknown column `{column}` in {where}. Check for a typo or pick the correct column name.', + 'console.error.mysql.unknownTable': 'Table `{table}` does not exist. Check for a typo or select a different table.', + 'console.error.postgres.syntax': 'SQL syntax error near `{snippet}`.', + 'console.error.postgres.undefinedColumn': 'Column `{column}` does not exist. Check for a typo or pick the correct column name.', + 'console.error.postgres.undefinedTable': 'Relation `{table}` does not exist. Check for a typo or pick a different table.', + 'console.error.genericSyntax': 'SQL syntax error — the database could not parse this statement.', + 'console.error.unknown': 'The statement failed to execute. See the technical detail below.', + 'console.error.jumpToPosition': 'Jump to position', + 'console.error.showDetail': 'Show technical detail', + 'console.error.hideDetail': 'Hide technical detail', + 'console.error.askAi': 'Ask AI to fix', + 'console.error.askAi.prompt': 'My SQL failed with the following error. Please explain what went wrong and propose a corrected version.\n\nDatasource type: {dbType}\n\nSQL:\n```sql\n{sql}\n```\n\nError:\n```\n{error}\n```', + + 'explain.title': 'Explain Plan', + 'explain.summary.title': 'Plan Summary', + 'explain.usesIndex': 'Index used', + 'explain.fullScan': 'Full scan', + 'explain.noDetails': 'No explain details.', + 'explain.subtitle.default': 'Execution plan breakdown.', + 'explain.subtitle.stages': 'Stages: {stages}', + + 'explain.highlight.stages': 'Stages', + 'explain.highlight.stage': 'Stage', + 'explain.highlight.indexes': 'Indexes', + 'explain.highlight.index': 'Index', + 'explain.highlight.rowsExamined': 'Rows examined', + 'explain.highlight.estimatedRows': 'Estimated rows', + 'explain.highlight.actualRows': 'Likely actual rows', + 'explain.highlight.docsExamined': 'Docs examined', + 'explain.highlight.keysExamined': 'Keys examined', + 'explain.highlight.rowsReturned': 'Rows returned', + 'explain.highlight.time': 'Time', + + 'explain.sql.noRows.withStages': 'Stages: {stages}. Detailed interpretation is not available yet.', + 'explain.sql.noRows.empty': 'Execution plan is empty. Detailed interpretation is not available yet.', + 'explain.sql.primary.withIndex': + 'Optimizer starts with {table} via {access} and uses index {index} to narrow the scan.', + 'explain.sql.primary.noIndex': + 'Optimizer starts with {table} via {access}, and no effective index is currently used.', + 'explain.sql.scan.withJoin': + 'The primary node is estimated to scan about {rows} rows, then join with {tables}.', + 'explain.sql.scan.singleTable': + 'The primary node is estimated to scan about {rows} rows on a single-table access path.', + 'explain.sql.joinOnly': 'The plan has {operators} operators, including joins on {tables}.', + 'explain.sql.optimization.filesort': + 'Plan includes filesort or temporary-table overhead; try aligning ORDER BY/GROUP BY with composite indexes.', + 'explain.sql.optimization.where': + 'Filters are applied in the plan; next optimize index selectivity on filter/join columns.', + 'explain.sql.optimization.direct': + 'Current path is straightforward; optimize further only if data volume requires it.', + 'explain.sql.stages': 'Execution stages: {stages}.', + 'explain.sql.indexes.observed': 'Indexes observed in plan: {indexes}.', + 'explain.sql.cost': 'Estimated total cost is about {cost}; you can still fine-tune indexes.', + 'explain.sql.index.hit.withList': 'Index usage: yes, hit {indexes}.', + 'explain.sql.index.hit.noList': 'Index usage: yes, an index is used.', + 'explain.sql.index.partial.withList': + 'Index usage: partial. Indexes {indexes} appear in plan, but some steps still do full scans.', + 'explain.sql.index.miss': 'Index usage: no, current plan does not hit a usable index.', + 'explain.sql.rows.estimated': 'Estimated rows to scan/process: about {rows}.', + 'explain.sql.rows.actual.filtered': 'Likely rows after filtering: about {rows} (rows × filtered%).', + 'explain.sql.rows.actual.pg': 'Actual rows processed (ANALYZE): about {rows}.', + 'explain.sql.rows.actual.needAnalyze': 'Actual rows need EXPLAIN ANALYZE. Turn on Analyze to measure them.', + 'explain.sql.rows.actual.noFiltered': 'Actual rows are not directly available; EXPLAIN has no filtered% for this step.', + 'explain.sql.rows.actual.unavailable.mysql': 'Need filtered% first', + 'explain.sql.rows.actual.unavailable.pg': 'Need ANALYZE first', + 'explain.sql.rows.actual.unavailable.generic': 'Actual rows unavailable', + 'explain.sql.mysql.access.summary': 'Access type: {access}.', + 'explain.sql.mysql.possibleKeys': 'Possible indexes: {indexes}.', + 'explain.sql.mysql.keyLength': 'Index key length: {lengths}.', + 'explain.sql.mysql.extra.summary': 'Extra: {details}.', + 'explain.sql.mysql.accessType.ALL': 'ALL (full table scan)', + 'explain.sql.mysql.accessType.INDEX': 'INDEX (full index scan)', + 'explain.sql.mysql.accessType.RANGE': 'RANGE (range scan, usually on an index interval)', + 'explain.sql.mysql.accessType.REF': 'REF (non-unique index lookup)', + 'explain.sql.mysql.accessType.EQ_REF': 'EQ_REF (unique lookup per joined row)', + 'explain.sql.mysql.accessType.CONST': 'CONST (constant lookup)', + 'explain.sql.mysql.accessType.SYSTEM': 'SYSTEM (single-row system table)', + 'explain.sql.mysql.accessType.NULL': 'NULL (no table access)', + 'explain.sql.mysql.accessType.OTHER': '{value}', + 'explain.sql.mysql.extra.usingWhere': 'Using where (read first, then filter)', + 'explain.sql.mysql.extra.usingFilesort': 'Using filesort (extra sorting work, often means order is not index-backed)', + 'explain.sql.mysql.extra.usingTemporary': 'Using temporary (temporary table involved)', + 'explain.sql.mysql.extra.usingIndexCondition': 'Using index condition (index condition pushdown is applied)', + 'explain.sql.mysql.extra.usingIndex': 'Using index (covering index read)', + 'explain.sql.mysql.extra.usingJoinBuffer': 'Using join buffer (join key may miss a proper index)', + 'explain.sql.mysql.extra.impossibleWhere': 'Impossible WHERE (planner found no matching rows)', + 'explain.sql.mysql.extra.raw': '{value}', + 'explain.sql.pg.operators': 'Main operators: {operators}.', + 'explain.sql.pg.seqScan': 'Seq Scan on {tables}: this step scans the whole table.', + 'explain.sql.pg.indexCond': 'Index condition: {condition}.', + 'explain.sql.pg.filter': 'Filter condition: {condition}.', + 'explain.sql.pg.rowsRemoved': 'Rows removed by filter: about {rows}.', + 'explain.sql.pg.sortKey': 'Sort key: {keys} (if no matching index, extra sort work is needed).', + 'explain.sql.pg.unknownRelation': 'unknown relation', + 'explain.sql.pg.nodeType.seqScan': 'Seq Scan (full table scan)', + 'explain.sql.pg.nodeType.indexScan': 'Index Scan (index lookup)', + 'explain.sql.pg.nodeType.indexScanBackward': 'Index Scan Backward (backward index walk)', + 'explain.sql.pg.nodeType.indexOnlyScan': 'Index Only Scan (covering index read)', + 'explain.sql.pg.nodeType.indexOnlyScanBackward': 'Index Only Scan Backward (backward covering index read)', + 'explain.sql.pg.nodeType.bitmapIndexScan': 'Bitmap Index Scan (build bitmap from index)', + 'explain.sql.pg.nodeType.bitmapHeapScan': 'Bitmap Heap Scan (fetch rows by bitmap)', + 'explain.sql.pg.nodeType.nestedLoop': 'Nested Loop (repeated join)', + 'explain.sql.pg.nodeType.hashJoin': 'Hash Join (hash-based join)', + 'explain.sql.pg.nodeType.mergeJoin': 'Merge Join (ordered merge join)', + 'explain.sql.pg.nodeType.sort': 'Sort (extra sorting)', + 'explain.sql.pg.nodeType.aggregate': 'Aggregate (grouping/aggregation)', + 'explain.sql.pg.nodeType.limit': 'Limit', + 'explain.sql.pg.nodeType.materialize': 'Materialize (cache intermediate result)', + + 'explain.doc.stages': '{engine} plan has {count} stage(s): {stages}.', + 'explain.doc.indexes.withList': 'Indexes used: {indexes}.', + 'explain.doc.indexes.hitNoList': 'An index is used in the plan.', + 'explain.doc.indexes.none': 'No index is used currently, so {engine} will scan more data.', + 'explain.doc.metrics': 'Execution metrics: {stats}.', + 'explain.doc.heaviestStage': 'Most cost is concentrated in stage {stage}, with an estimated {cost}%.', + 'explain.doc.mongoSuggest.withFields': 'Consider creating indexes on filter fields first: {fields}.', + 'explain.doc.mongoSuggest.default': 'Consider indexing filter fields and narrowing returned fields.', + 'explain.doc.genericSuggest': + 'Consider optimizing filter predicates, index hit rate, and returned field count for high-cost stages.', + 'explain.doc.stat.keys': 'keys examined {value}', + 'explain.doc.stat.docs': 'docs examined {value}', + 'explain.doc.stat.returned': 'rows returned {value}', + 'explain.doc.stat.time': 'time {value}ms', + + 'danger.sql.title': 'Confirm SQL execution', + 'danger.sql.subtitle': 'This SQL may scan data without an index.', + 'danger.mongo.title': 'Confirm Mongo execution', + 'danger.mongo.subtitle': 'This query may scan data without an index.', + 'danger.detected': 'Detected: {value}', + 'danger.detectedUnknown': 'Detected: unknown (explain unavailable)', + 'danger.review': 'Review', + 'danger.stages': 'Stages: {stages}', + 'danger.indexes': 'Indexes: {indexes}', + 'danger.considerSqlIndex': 'Consider adding an index on filter/join fields.', + 'danger.runExplain': 'Run Explain to verify whether indexes are used.', + 'danger.considerMongoIndex': 'Consider adding an index on filter fields.', + 'danger.cancel': 'Cancel', + 'danger.runAnyway': 'Run anyway', + 'danger.blockTitle': 'Execution blocked', + 'danger.warnTitle': 'Confirm execution', + 'danger.riskSubtitle': 'This statement was flagged by the risk engine.', + 'danger.levelHigh': 'High risk', + 'danger.levelMedium': 'Medium risk', + + 'result.noDocumentsMatched': 'No documents matched.', + 'result.zeroDocuments': '0 documents.', + 'result.zeroItems': '0 items.', + 'result.zeroRows': '0 rows.', + }, + zh: { + 'common.listSeparator': '、', + 'common.metricSeparator': ',', + + 'status.failedWithMessage': '失败 | {message}', + 'status.skippedEmpty': '已跳过 | 空语句', + 'status.skippedWithMessage': '已跳过 | {message}', + 'status.safetyNoIndex': '安全检查 | 未命中索引', + 'status.running': '执行中...', + 'status.explainUsesIndex': '执行计划 | 命中索引', + 'status.explainNoIndex': '执行计划 | 未命中索引', + 'status.explainNotSupported': '当前数据源不支持执行计划。', + 'status.success': '成功', + + 'validation.sql.limitBeforeOrderBy': + 'SQL 语法:ORDER BY 必须位于 LIMIT 之前。示例:SELECT ... ORDER BY id DESC LIMIT 50;', + 'validation.mongo.invalidStatement': '无效的 Mongo 语句。', + 'validation.mongo.selectDatabase': '请选择数据库后再执行 Mongo 语句。', + + 'console.precheck.unclosedSingleQuote': '字符串引号未闭合 —— 缺少结束的单引号。', + 'console.precheck.unclosedDoubleQuote': '标识符引号未闭合 —— 缺少结束的双引号。', + 'console.precheck.unclosedBacktick': '标识符引号未闭合 —— 缺少结束的反引号。', + 'console.precheck.unclosedDollarQuote': 'PostgreSQL 美元引号字符串未闭合 —— 缺少结束的 "$tag$"。', + 'console.precheck.unclosedBlockComment': '块注释未闭合 —— 缺少 "*/"。', + 'console.precheck.unbalancedParenOpen': '括号不匹配 —— 多了未闭合的左括号 "("。', + 'console.precheck.unbalancedParenClose': '括号不匹配 —— 多了无对应的右括号 ")"。', + 'console.precheck.danglingComma': '多余的逗号 —— 请去掉末尾的逗号,或补全后面的表达式。', + 'console.precheck.fix.closeSingleQuote': "在末尾补上 '", + 'console.precheck.fix.closeDoubleQuote': '在末尾补上 "', + 'console.precheck.fix.closeBacktick': '在末尾补上 `', + 'console.precheck.fix.closeDollarQuote': '补全美元引号字符串', + 'console.precheck.fix.closeBlockComment': '在末尾补上 */', + 'console.precheck.fix.closeParen': '在末尾补上 )', + 'console.precheck.fix.removeComma': '移除多余的逗号', + 'console.precheck.applyFix': '应用建议的修复', + 'console.precheck.heading': '执行前建议', + + 'console.error.mysql.syntaxNear': 'SQL 语法错误,问题出在 `{snippet}` 附近。', + 'console.error.mysql.unknownColumn': '不存在的列 `{column}`(位于 {where})。请检查是否拼写错误或换成正确的列名。', + 'console.error.mysql.unknownTable': '表 `{table}` 不存在。请检查是否拼写错误或换一张表。', + 'console.error.postgres.syntax': 'SQL 语法错误,问题出在 `{snippet}` 附近。', + 'console.error.postgres.undefinedColumn': '列 `{column}` 不存在。请检查拼写或换成正确的列名。', + 'console.error.postgres.undefinedTable': '关系 `{table}` 不存在。请检查拼写或换一张表。', + 'console.error.genericSyntax': 'SQL 语法错误 —— 数据库无法解析这条语句。', + 'console.error.unknown': '语句执行失败。请展开下方的技术细节查看原始错误。', + 'console.error.jumpToPosition': '跳转到出错位置', + 'console.error.showDetail': '查看技术细节', + 'console.error.hideDetail': '收起技术细节', + 'console.error.askAi': '让 AI 帮我修复', + 'console.error.askAi.prompt': '我执行的 SQL 报错了,请解释原因并给出修复后的版本。\n\n数据源类型:{dbType}\n\nSQL:\n```sql\n{sql}\n```\n\n错误:\n```\n{error}\n```', + + 'explain.title': '执行计划', + 'explain.summary.title': '计划解读', + 'explain.usesIndex': '命中索引', + 'explain.fullScan': '全量扫描', + 'explain.noDetails': '暂无 explain 细节。', + 'explain.subtitle.default': '执行计划拆解。', + 'explain.subtitle.stages': '阶段:{stages}', + + 'explain.highlight.stages': '阶段', + 'explain.highlight.stage': '当前阶段', + 'explain.highlight.indexes': '索引', + 'explain.highlight.index': '索引', + 'explain.highlight.rowsExamined': '扫描行数', + 'explain.highlight.estimatedRows': '预计操作行数', + 'explain.highlight.actualRows': '实际可能操作行数', + 'explain.highlight.docsExamined': '扫描文档数', + 'explain.highlight.keysExamined': '扫描键数', + 'explain.highlight.rowsReturned': '返回行数', + 'explain.highlight.time': '耗时', + + 'explain.sql.noRows.withStages': '阶段:{stages},暂时无法生成详细解读。', + 'explain.sql.noRows.empty': '执行计划为空,暂时无法生成详细解读。', + 'explain.sql.primary.withIndex': + '优化器预计先从 {table} 开始,访问方式为 {access},并使用索引 {index} 缩小扫描范围。', + 'explain.sql.primary.noIndex': '优化器预计先从 {table} 开始,访问方式为 {access},当前没有命中有效索引。', + 'explain.sql.scan.withJoin': '主节点预估扫描约 {rows} 行,随后与 {tables} 进行关联。', + 'explain.sql.scan.singleTable': '主节点预估扫描约 {rows} 行,当前计划为单表访问路径。', + 'explain.sql.joinOnly': '执行计划包含 {operators} 个算子,包含对 {tables} 的关联步骤。', + 'explain.sql.optimization.filesort': '计划包含额外排序或临时表开销,建议让 ORDER BY / GROUP BY 尽量命中复合索引。', + 'explain.sql.optimization.where': '过滤条件已在计划中生效,可继续优化过滤字段和关联字段上的索引选择性。', + 'explain.sql.optimization.direct': '当前路径较直接,可根据数据规模决定是否需要进一步索引优化。', + 'explain.sql.stages': '执行阶段:{stages}。', + 'explain.sql.indexes.observed': '计划中发现索引:{indexes}。', + 'explain.sql.cost': '综合估算成本约 {cost},可继续做索引微调。', + 'explain.sql.index.hit.withList': '索引命中:是,命中 {indexes}。', + 'explain.sql.index.hit.noList': '索引命中:是,计划使用了索引。', + 'explain.sql.index.partial.withList': '索引命中:部分命中,计划里出现了 {indexes},但仍有步骤在做全扫描。', + 'explain.sql.index.miss': '索引命中:否,当前计划没有命中可用索引。', + 'explain.sql.rows.estimated': '预计会扫描/处理约 {rows} 行数据。', + 'explain.sql.rows.actual.filtered': '按 filtered 估算,实际可能操作约 {rows} 行数据(rows × filtered%)。', + 'explain.sql.rows.actual.pg': '基于 ANALYZE,实际操作约 {rows} 行数据。', + 'explain.sql.rows.actual.needAnalyze': '实际行数需要 EXPLAIN ANALYZE;开启 Analyze 后才能测出来。', + 'explain.sql.rows.actual.noFiltered': '这条计划没有给出 filtered%,暂时无法更精确估算实际行数。', + 'explain.sql.rows.actual.unavailable.mysql': '需要 filtered% 才能估算', + 'explain.sql.rows.actual.unavailable.pg': '需要先开启 Analyze', + 'explain.sql.rows.actual.unavailable.generic': '暂无实际行数', + 'explain.sql.mysql.access.summary': '访问类型:{access}。', + 'explain.sql.mysql.possibleKeys': '候选索引:{indexes}。', + 'explain.sql.mysql.keyLength': '索引键长:{lengths}。', + 'explain.sql.mysql.extra.summary': '额外信息:{details}。', + 'explain.sql.mysql.accessType.ALL': 'ALL(全表扫描)', + 'explain.sql.mysql.accessType.INDEX': 'INDEX(全索引扫描)', + 'explain.sql.mysql.accessType.RANGE': 'RANGE(范围扫描,通常会用到索引区间)', + 'explain.sql.mysql.accessType.REF': 'REF(普通索引等值查找)', + 'explain.sql.mysql.accessType.EQ_REF': 'EQ_REF(关联时每行唯一索引查找)', + 'explain.sql.mysql.accessType.CONST': 'CONST(常量级查找)', + 'explain.sql.mysql.accessType.SYSTEM': 'SYSTEM(系统表单行访问)', + 'explain.sql.mysql.accessType.NULL': 'NULL(无需访问表)', + 'explain.sql.mysql.accessType.OTHER': '{value}', + 'explain.sql.mysql.extra.usingWhere': 'Using where(先读取再过滤)', + 'explain.sql.mysql.extra.usingFilesort': 'Using filesort(需要额外排序,通常说明排序没走索引)', + 'explain.sql.mysql.extra.usingTemporary': 'Using temporary(需要临时表)', + 'explain.sql.mysql.extra.usingIndexCondition': 'Using index condition(索引条件下推)', + 'explain.sql.mysql.extra.usingIndex': 'Using index(覆盖索引读取)', + 'explain.sql.mysql.extra.usingJoinBuffer': 'Using join buffer(关联键可能缺少合适索引)', + 'explain.sql.mysql.extra.impossibleWhere': 'Impossible WHERE(优化器判断结果为空)', + 'explain.sql.mysql.extra.raw': '{value}', + 'explain.sql.pg.operators': '主要算子:{operators}。', + 'explain.sql.pg.seqScan': '在 {tables} 上出现 Seq Scan,这一步会整表扫描。', + 'explain.sql.pg.indexCond': '索引条件:{condition}。', + 'explain.sql.pg.filter': '过滤条件:{condition}。', + 'explain.sql.pg.rowsRemoved': '被过滤掉的行数约 {rows}。', + 'explain.sql.pg.sortKey': '排序键:{keys}(若无匹配索引,会有额外排序开销)。', + 'explain.sql.pg.unknownRelation': '未知表', + 'explain.sql.pg.nodeType.seqScan': 'Seq Scan(整表扫描)', + 'explain.sql.pg.nodeType.indexScan': 'Index Scan(索引查找)', + 'explain.sql.pg.nodeType.indexScanBackward': 'Index Scan Backward(倒序索引扫描)', + 'explain.sql.pg.nodeType.indexOnlyScan': 'Index Only Scan(覆盖索引读取)', + 'explain.sql.pg.nodeType.indexOnlyScanBackward': 'Index Only Scan Backward(倒序覆盖索引扫描)', + 'explain.sql.pg.nodeType.bitmapIndexScan': 'Bitmap Index Scan(索引生成位图)', + 'explain.sql.pg.nodeType.bitmapHeapScan': 'Bitmap Heap Scan(按位图回表)', + 'explain.sql.pg.nodeType.nestedLoop': 'Nested Loop(循环关联)', + 'explain.sql.pg.nodeType.hashJoin': 'Hash Join(哈希关联)', + 'explain.sql.pg.nodeType.mergeJoin': 'Merge Join(归并关联)', + 'explain.sql.pg.nodeType.sort': 'Sort(额外排序)', + 'explain.sql.pg.nodeType.aggregate': 'Aggregate(分组聚合)', + 'explain.sql.pg.nodeType.limit': 'Limit', + 'explain.sql.pg.nodeType.materialize': 'Materialize(缓存中间结果)', + + 'explain.doc.stages': '{engine} 计划分为 {count} 个阶段:{stages}。', + 'explain.doc.indexes.withList': '已命中索引:{indexes}。', + 'explain.doc.indexes.hitNoList': '计划已命中索引。', + 'explain.doc.indexes.none': '当前未命中索引,{engine} 需要扫描更多数据。', + 'explain.doc.metrics': '执行指标:{stats}。', + 'explain.doc.heaviestStage': '主要耗时集中在 {stage} 阶段,预计占比约 {cost}%。', + 'explain.doc.mongoSuggest.withFields': '建议优先为过滤字段建立索引:{fields}。', + 'explain.doc.mongoSuggest.default': '建议优先为过滤条件涉及字段建立索引,并缩小返回字段范围。', + 'explain.doc.genericSuggest': '建议优先优化高成本阶段对应的过滤条件、索引命中率和返回字段数量。', + 'explain.doc.stat.keys': '键扫描 {value}', + 'explain.doc.stat.docs': '文档扫描 {value}', + 'explain.doc.stat.returned': '返回 {value}', + 'explain.doc.stat.time': '耗时 {value}ms', + + 'danger.sql.title': '确认执行 SQL', + 'danger.sql.subtitle': '这条 SQL 可能在未命中索引时扫描大量数据。', + 'danger.mongo.title': '确认执行 Mongo 查询', + 'danger.mongo.subtitle': '这条查询可能在未命中索引时扫描大量数据。', + 'danger.detected': '检测结果:{value}', + 'danger.detectedUnknown': '检测结果:未知(执行计划不可用)', + 'danger.review': '请确认', + 'danger.stages': '阶段:{stages}', + 'danger.indexes': '索引:{indexes}', + 'danger.considerSqlIndex': '建议在过滤/关联字段上补充索引。', + 'danger.runExplain': '建议先运行执行计划确认是否命中索引。', + 'danger.considerMongoIndex': '建议在过滤字段上补充索引。', + 'danger.cancel': '取消', + 'danger.runAnyway': '仍然执行', + 'danger.blockTitle': '执行已拦截', + 'danger.warnTitle': '确认执行', + 'danger.riskSubtitle': '该语句被风险引擎标记。', + 'danger.levelHigh': '高风险', + 'danger.levelMedium': '中风险', + + 'result.noDocumentsMatched': '未匹配到文档。', + 'result.zeroDocuments': '0 条文档。', + 'result.zeroItems': '0 个条目。', + 'result.zeroRows': '0 行。', + }, +} + +const APP_MESSAGES: Record> = { + en: { + ...APP_CONSOLE_MESSAGES.en, + 'common.count': '{count} item(s)', + 'common.test': 'Test', + 'common.edit': 'Edit', + 'common.delete': 'Delete', + 'common.cancel': 'Cancel', + 'common.close': 'Close', + 'common.enabled': 'Enabled', + 'common.disabled': 'Disabled', + 'common.save': 'Save', + 'common.clear': 'Clear', + 'common.copy': 'Copy', + 'common.back': 'Back', + 'common.copied': 'Copied.', + 'common.commandCopied': 'Command copied.', + 'common.copyFailed': 'Copy failed.', + 'common.clipboardUnavailable': 'Clipboard is unavailable.', + 'common.name': 'Name', + 'common.type': 'Type', + 'common.host': 'Host', + 'common.port': 'Port', + 'common.username': 'Username', + 'common.password': 'Password', + 'common.database': 'Database', + 'common.custom': 'Custom', + 'common.optionalDefaultHint': 'Optional. Leave blank to use the default.', + 'common.less': 'Less', + 'common.more': 'More', + 'common.show': 'Show', + 'common.hide': 'Hide', + 'common.cannotUndo': 'This action cannot be undone.', + 'common.color.green': 'Green', + 'common.color.blue': 'Blue', + 'common.color.yellow': 'Yellow', + 'common.color.orange': 'Orange', + 'common.color.red': 'Red', + 'common.color.purple': 'Purple', + 'common.color.pink': 'Pink', + 'common.color.gray': 'Gray', + + 'status.connected': 'Connected', + 'status.failed': 'Failed', + 'status.testing': 'Testing', + 'status.testingEllipsis': 'Testing...', + 'status.unknown': 'Unknown', + + 'nav.sources': 'Sources', + 'nav.history': 'History', + 'nav.dataSensitivity': 'Data Sensitivity', + 'nav.riskRules': 'Risk Rules', + 'nav.aiSettings': 'AI Configuration', + 'nav.my': 'My', + + 'route.datasources': 'Data Sources', + 'route.datasourceCreate': 'New Data Source', + 'route.datasourceEdit': 'Edit Data Source', + 'route.console': 'Console', + 'route.history': 'History', + 'route.sensitivityList': 'Data Sensitivity', + 'route.riskRules': 'Risk Rules', + 'route.riskRulesCreate': 'New Risk Rule', + 'route.riskRulesEdit': 'Edit Risk Rule', + 'route.aiSettings': 'AI Configuration', + 'route.aiSettingsCreate': 'New AI Configuration', + 'route.aiSettingsEdit': 'Edit AI Configuration', + 'route.my': 'My', + 'route.default': 'Dashboard', + 'auth.login.loadingTitle': 'Checking your session', + 'auth.login.loadingDescription': 'Please wait while FutrixData restores this device.', + 'auth.login.title': 'Sign in to FutrixData', + 'auth.login.description': 'Sign in to sync your account, devices, and Pro features. Local Free usage is available without signing in.', + 'auth.login.start': 'Open sign-in in browser', + 'auth.login.starting': 'Starting...', + 'auth.login.noBrowser': 'Sign in on another device', + 'auth.login.useCode': 'Sign in with code', + 'auth.login.waitingTitle': 'Waiting for sign-in', + 'auth.login.waitingDescription': 'Complete the login in your browser, then return here.', + 'auth.login.manualHint': 'or enter code from browser', + 'auth.login.urlLabel': 'Sign-in link', + 'auth.login.codeLabel': 'Manual code', + 'auth.login.codePlaceholder': 'A3F-K9M', + 'auth.login.submitCode': 'Submit', + 'auth.login.back': 'Back', + 'auth.login.cancel': 'Cancel', + 'auth.login.expired': 'This sign-in session expired. Please try again.', + 'auth.notice.signInForRiskRules': 'Sign in to create, edit, delete, import, or export custom risk rules.', + 'auth.notice.signInForSensitivityRules': 'Sign in to customize sensitivity rules.', + 'startupRecovery.eyebrow': 'Startup recovery', + 'startupRecovery.loadingTitle': 'Checking local data', + 'startupRecovery.loadingDescription': 'Please wait while FutrixData opens this device.', + 'startupRecovery.title': 'FutrixData could not open local data', + 'startupRecovery.note': + 'Your local data has not been deleted. If recovery is impossible, FutrixData can move the old encrypted data to a retained folder and start with fresh local data after you confirm.', + 'startupRecovery.dataPath': 'Local data file', + 'startupRecovery.versionInfo': 'File version', + 'startupRecovery.versionValue': 'Written by {writer}; requires {minimum} or newer.', + 'startupRecovery.versionUnknown': 'unknown', + 'startupRecovery.retentionPath': 'Retained data folder', + 'startupRecovery.confirmMoveAside': 'I understand the old encrypted data will be moved aside and FutrixData will start fresh on this device.', + 'startupRecovery.retrying': 'Retrying...', + 'startupRecovery.moving': 'Moving data...', + 'startupRecovery.actionFailed': 'The action could not be completed. Open logs for details or try again.', + 'startupRecovery.action.retry': 'Retry', + 'startupRecovery.action.updateApp': 'Update FutrixData', + 'startupRecovery.action.openLogs': 'Open Logs', + 'startupRecovery.action.moveAside': 'Move Old Data and Start Fresh', + 'startupRecovery.reason.app_too_old': 'This version of FutrixData is too old to read local data written by a newer version. Update FutrixData and try again.', + 'startupRecovery.reason.keychain_unavailable': 'FutrixData cannot access the OS keychain. Unlock the keychain or restart the device, then retry.', + 'startupRecovery.reason.key_mismatch': 'The local encrypted data could not be opened with this device key.', + 'startupRecovery.reason.corrupt_file': 'The local encrypted data appears damaged or incomplete.', + 'startupRecovery.reason.migration_failed': 'FutrixData could not safely migrate local data. The original files were kept in place.', + 'startupRecovery.reason.unknown': 'FutrixData could not open local data during startup.', + + 'skill.install.title': 'Set Up AI Agent Integration', + 'skill.install.subtitle': 'Install Skill and MCP Server to give your AI agents secure, controlled access to your data center.', + 'skill.install.alreadyInstalled': 'Installed', + 'skill.install.notInstalled': 'Not installed', + 'skill.install.notDetected': 'Not detected', + 'skill.install.skip': 'Skip', + 'skill.install.install': 'Install', + 'skill.install.installing': 'Installing...', + 'skill.install.done': 'Done', + 'skill.install.skill': 'Skill', + 'skill.install.mcp': 'MCP', + 'skill.install.codexSetupLabel': 'Codex plugin', + 'skill.install.codexSetupReady': 'Install the FutrixData plugin in Codex first, then keep MCP selected here to authorize Codex with a local access key.', + 'skill.install.codexSetupNotDetected': 'Open Codex once to create its local config, install the FutrixData plugin there, then return here to authorize MCP access.', + 'skill.install.codexSetupAuthorized': 'Codex MCP is already authorized. Reload Codex after installing the plugin so it picks up FutrixData tools.', + 'skill.install.codexSetupSelect': 'Use plugin setup', + 'skill.agentApprovalPolicyNotice': 'Third-party agents cannot approve FutrixData operations. Any Skill, MCP, or agent-key CLI call that requires approval is rejected and recorded in Agent Audit.', + 'skill.install.sensitivityGrantLabel': 'Also let these agents classify data sensitivity', + 'skill.install.sensitivityGrantHint': 'Off by default. Without this, sensitivity write tools (custom rules, save/delete reports) stay locked. You can revoke or grant per-agent later from the My page.', + 'skill.install.sensitivityGrantShort': 'Sensitivity policy', + 'skill.install.datasourceGrantLabel': 'Also let these agents add datasources', + 'skill.install.datasourceGrantHint': 'Off by default. Allows create/add datasource tools only. Agents still cannot update/delete datasources, and cannot create trusted or dangerous datasources.', + 'skill.install.datasourceGrantShort': 'Datasource creation', + 'skill.install.grantPartialTitle': 'Install succeeded, but a grant did not apply', + 'skill.install.grantPartialHint': 'The agent is installed, but one selected permission is not active. Toggle it manually from the My page after retrying.', + 'skill.install.sensitivityGrantPartialTitle': 'Install succeeded, but the sensitivity grant did not apply', + 'skill.install.sensitivityGrantPartialHint': 'The agent is installed but cannot run sensitivity classification yet. Toggle the grant manually from the My page after retrying.', + 'skill.manage.title': 'AI Skill', + 'skill.manage.desc': 'Manage FutrixData Skill for your AI coding agents. Install to enable secure access; uninstall to remove.', + 'skill.manage.install': 'Install', + 'skill.manage.uninstall': 'Uninstall', + 'skill.manage.installSuccess': 'Skill installed for {name}.', + 'skill.manage.installError': 'Failed to install skill for {name}: {message}', + 'skill.manage.uninstallSuccess': 'Skill uninstalled for {name}.', + 'skill.manage.uninstallError': 'Failed to uninstall skill for {name}: {message}', + 'skill.manage.identityTitle': 'Agent identity', + 'skill.manage.agentNameLabel': 'Name', + 'skill.manage.agentNamePlaceholder': 'agent-xxxx', + 'skill.manage.accessKeyLabel': 'Access key', + 'skill.manage.copyKey': 'Copy', + 'skill.manage.keyCopied': 'Copied ✓', + 'skill.manage.copyFailed': 'Failed to copy access key. Copy it manually from the input.', + 'skill.manage.hideKey': 'Hide', + 'skill.manage.rename': 'Save', + 'skill.manage.renameSaving': 'Saving…', + 'skill.manage.renameSuccess': 'Agent renamed to {name}.', + 'skill.manage.renameError': 'Failed to rename agent: {message}', + 'skill.manage.revoke': 'Revoke access', + 'skill.manage.unrevoke': 'Reinstate', + 'skill.manage.revoked': 'Revoked', + 'skill.manage.revokeSuccess': 'Agent access revoked.', + 'skill.manage.revokeError': 'Failed to revoke agent: {message}', + 'skill.manage.unrevokeSuccess': 'Agent access reinstated.', + 'skill.manage.unrevokeError': 'Failed to reinstate agent: {message}', + 'skill.manage.revokeConfirmTitle': 'Revoke agent access?', + 'skill.manage.revokeConfirmBody': 'This agent\'s access key will stop working immediately. Audit history is preserved so you can still inspect past activity.', + 'skill.manage.revokeConfirm': 'Revoke', + 'skill.manage.noIdentity': 'Install the skill to mint a per-install access key.', + + 'skill.manage.manualSectionTitle': 'Manual-install agents', + 'skill.manage.manualSectionDesc': 'Access keys generated for AI agents you connected manually (anything beyond the four presets above). Rename, copy, or revoke them here.', + 'skill.manage.manualEmpty': 'No manual agents yet. Click "New manual agent" to mint one.', + 'skill.manage.manualNewBtn': 'New manual agent', + 'skill.manage.manualNewSuccess': 'Manual agent {name} created.', + 'skill.manage.manualNewError': 'Failed to create manual agent: {message}', + 'skill.manage.manualNewPrompt': 'Name for the new manual agent (e.g. zed-research)', + 'skill.manage.manualNewDefault': 'manual-agent', + 'skill.newManual.dialogTitle': 'New manual agent', + 'skill.newManual.stage1Desc': 'Pick a name to identify this access key in audit logs.', + 'skill.newManual.stage2Desc': 'Use these snippets to wire {name} into any AI agent or MCP client.', + 'skill.newManual.nameLabel': 'Agent name', + 'skill.newManual.namePlaceholder': 'e.g. zed-research', + 'skill.newManual.create': 'Create agent', + 'skill.newManual.creating': 'Creating…', + 'skill.newManual.cancel': 'Cancel', + 'skill.newManual.done': 'Done', + 'skill.newManual.errorPrefix': 'Could not create agent: {message}', + 'skill.newManual.infoErrorPrefix': 'Agent created, but loading install snippets failed: {message}', + 'skill.newManual.grantErrorPrefix': 'Agent created, but the sensitivity grant did not apply: {message}. Toggle it manually from the My page.', + 'skill.newManual.sensitivityGrantErrorPrefix': 'Sensitivity grant did not apply: {message}.', + 'skill.newManual.datasourceGrantErrorPrefix': 'Datasource creation grant did not apply: {message}.', + 'skill.newManual.sensitivityGrantLabel': 'Allow this agent to update sensitivity-classification policy', + 'skill.newManual.sensitivityGrantHint': 'Off by default. Sensitivity write tools (custom rules, save/delete reports) stay locked until you grant this. You can change it later from the manage screen.', + 'skill.newManual.datasourceGrantLabel': 'Allow this agent to add datasources', + 'skill.newManual.datasourceGrantHint': 'Off by default. Enables add/create datasource tools only; update/delete stay approval-gated and trusted/danger creation is blocked.', + 'skill.manage.sensitivityGrantLabel': 'Sensitivity policy', + 'skill.manage.sensitivityGrantOn': 'Granted', + 'skill.manage.sensitivityGrantOff': 'Not granted', + 'skill.manage.sensitivityGrantHint': 'Controls whether this agent can call sensitivity write tools (set custom rules, save/delete reports). Read tools are always allowed.', + 'skill.manage.sensitivityGrantAllow': 'Grant', + 'skill.manage.sensitivityGrantRevoke': 'Revoke', + 'skill.manage.sensitivityGrantOnSuccess': 'Granted sensitivity-policy access to {name}.', + 'skill.manage.sensitivityGrantOffSuccess': 'Revoked sensitivity-policy access from {name}.', + 'skill.manage.sensitivityGrantError': 'Failed to update sensitivity grant: {message}', + 'skill.manage.datasourceGrantLabel': 'Datasource creation', + 'skill.manage.datasourceGrantOn': 'Granted', + 'skill.manage.datasourceGrantOff': 'Not granted', + 'skill.manage.datasourceGrantHint': 'Controls whether this agent can call add/create datasource tools without an approval prompt. Update/delete stay locked.', + 'skill.manage.datasourceGrantAllow': 'Grant', + 'skill.manage.datasourceGrantRevoke': 'Revoke', + 'skill.manage.datasourceGrantOnSuccess': 'Granted datasource-creation access to {name}.', + 'skill.manage.datasourceGrantOffSuccess': 'Revoked datasource-creation access from {name}.', + 'skill.manage.datasourceGrantError': 'Failed to update datasource grant: {message}', + 'skill.manage.manualBadge': 'Manual', + 'skill.manage.manualPathLabel': 'Source', + 'skill.manage.manualPathValue': 'Manually installed', + 'skill.manage.showInstall': 'Show install snippet', + + 'mcp.manage.title': 'MCP Server', + 'mcp.manage.desc': 'Install the FutrixData MCP Server to give AI agents direct tool access to your datasources via the Model Context Protocol.', + 'mcp.manage.installSuccess': 'MCP Server installed for {name}.', + 'mcp.manage.installError': 'Failed to install MCP Server for {name}: {message}', + 'mcp.manage.uninstallSuccess': 'MCP Server uninstalled for {name}.', + 'mcp.manage.uninstallError': 'Failed to uninstall MCP Server for {name}: {message}', + 'mcp.manage.codexAuthorize': 'Authorize Codex', + 'mcp.manage.codexDisconnect': 'Disconnect Codex', + 'mcp.manage.codexDetectedHint': 'For the Codex plugin, this writes the FutrixData MCP entry and binds a per-Codex access key. Install the plugin in Codex first, then authorize here.', + 'mcp.manage.codexAuthorizedHint': 'Codex is authorized. Reload Codex if the plugin was installed while FutrixData was already open.', + 'mcp.manage.codexNotDetectedHint': 'Open Codex once so FutrixData can find its config, then return here to authorize the plugin.', + 'codex.connect.title': 'Authorize Codex?', + 'codex.connect.desc': 'The FutrixData Codex plugin is requesting local access. Authorize only if you installed the plugin and want Codex to use FutrixData MCP tools on this device.', + 'codex.connect.confirm': 'Authorize Codex', + 'codex.connect.authorizing': 'Authorizing...', + 'codex.connect.cancel': 'Cancel', + 'codex.connect.success': 'Codex is authorized. Reload Codex so the FutrixData plugin can use the local MCP bridge.', + 'codex.connect.error': 'Failed to authorize Codex: {message}', + 'skill.manualInstall.button': 'Manual install', + 'skill.manualInstall.title': 'Manual install — any AI agent', + 'skill.manualInstall.subtitle': 'Copy these snippets into any AI agent or MCP client that you use outside the four presets. Skill and MCP are open standards — not just Claude/Cursor/Codex/OpenCode.', + 'skill.manualInstall.cliPathLabel': 'CLI binary', + 'skill.manualInstall.skillHeading': 'Skill file', + 'skill.manualInstall.skillDesc': 'Drop this markdown into your agent\'s skill/rule directory. Pick the flavor that matches your client.', + 'skill.manualInstall.mcpHeading': 'MCP server configuration', + 'skill.manualInstall.mcpDesc': 'Paste one of these snippets into your client\'s MCP config. The CLI path is already filled in for your machine.', + 'skill.manualInstall.suggestedPath': 'Path', + 'skill.manualInstall.copy': 'Copy', + 'skill.manualInstall.copyContent': 'Copy content', + 'skill.manualInstall.copied': 'Copied ✓', + 'skill.manualInstall.close': 'Close', + 'skill.manualInstall.loading': 'Loading…', + 'skill.manualInstall.agentNameLabel': 'Agent name', + 'skill.manualInstall.agentNamePlaceholder': 'agent-xxxx', + 'skill.manualInstall.agentNameSaving': 'Saving name…', + 'skill.manualInstall.boundAccessKey': 'This install bundle is already bound to one agent identity.', + 'skill.manualInstall.snippets.standard-json.label': 'Standard MCP (JSON)', + 'skill.manualInstall.snippets.standard-json.notes': 'Works with Claude Code, Cursor, Windsurf, Continue, Zed, and most MCP-capable clients. Merge into the existing mcpServers map.', + 'skill.manualInstall.snippets.codex-toml.label': 'Codex (TOML)', + 'skill.manualInstall.snippets.codex-toml.notes': 'For Codex and any client that loads a TOML MCP section.', + 'skill.manualInstall.snippets.opencode-json.label': 'OpenCode (JSON)', + 'skill.manualInstall.snippets.opencode-json.notes': 'OpenCode uses a different shape: a top-level mcp map, with command as an array and type:"local".', + + 'my.menu.label': 'My Menu', + 'my.menu.account': 'Account', + 'my.menu.knowledgeBase': 'Knowledge Base', + 'my.menu.language': 'Language', + 'my.menu.skill': 'AI Skill & MCP', + 'my.menu.sensitivity': 'Sensitivity', + 'my.menu.sensitivityConfig': 'Sensitivity Config', + 'my.menu.sensitivityScan': 'Sensitivity Scan', + 'my.menu.settings': 'Settings', + 'my.menu.chooseHint': 'Choose a menu item to continue.', + + 'my.account.title': 'Account', + 'my.account.desc': 'Review this device, sign in for account features, or manage your active session.', + 'my.account.emailLabel': 'Email', + 'my.account.planLabel': 'Plan', + 'my.account.statusLabel': 'Status', + 'my.account.statusValue.active': 'Active', + 'my.account.statusValue.proExpired': 'Pro expired', + 'my.account.statusValue.trial': 'Trial active', + 'my.account.expiresLabel': 'Expires on', + 'my.account.expiredOnLabel': 'Expired on', + 'my.account.trialExpiresLabel': 'Trial expires on', + 'my.account.planExpiredBanner': 'Your Pro access expired. You are currently on the Free plan. Renew Pro to unlock Pro features.', + 'my.account.deviceLimitLabel': 'Device limit', + 'my.account.deviceLimitValue': 'Up to {limit} devices', + 'my.account.deviceLabel': 'Current device', + 'my.account.versionLabel': 'Version', + 'my.account.versionFallback': 'dev build', + 'my.account.refreshDevices': 'Refresh Devices', + 'my.account.logout': 'Sign Out', + 'my.account.devicesTitle': 'Active devices', + 'my.account.deviceUsage': '{used}/{limit} devices in use', + 'my.account.loadingDevices': 'Loading devices...', + 'my.account.noDevices': 'No active devices.', + 'my.account.currentDevice': 'This device', + 'my.account.unnamedDevice': 'Unnamed device', + 'my.account.unnamedDeviceOn': 'Unnamed {platform} device', + 'my.account.unknownPlatform': 'Unknown system', + 'my.account.removeDevice': 'Remove Device', + 'my.account.logoutSuccess': 'Signed out on this device.', + 'my.account.logoutError': 'Failed to sign out: {message}', + 'my.account.loadDevicesError': 'Failed to load devices: {message}', + 'my.account.removeSuccess': 'Device removed.', + 'my.account.removeError': 'Failed to remove device: {message}', + 'my.account.update.availableBadge': 'Update available', + 'my.account.update.latestLabel': 'Latest version', + 'my.account.update.releaseNotes': 'Release notes', + 'my.account.update.updateNow': 'Update now', + 'my.account.update.checkButton': 'Check for updates', + 'my.account.update.checking': 'Checking...', + 'my.account.update.upToDate': "You're on the latest version.", + 'my.account.update.signInHint': 'Sign in to check for updates.', + 'my.account.update.error': 'Update check failed: {message}', + 'my.account.update.openError': 'Could not open the download page: {message}', + 'my.account.update.availableNotice': 'New version {latest} is available.', + 'my.account.update.dismiss': 'Dismiss', + 'plan.name.free': 'Free', + 'plan.name.pro': 'Pro', + 'plan.name.trial': 'Trial', + 'plan.notice.datasourceLimit': '{plan} plan supports up to {limit} datasources. Upgrade to Pro to add more.', + 'plan.notice.riskRules': 'Custom risk rules are not available on the {plan} plan. Upgrade to Pro to create, edit, import, or export them.', + 'plan.notice.deviceLimit': '{plan} plan supports up to {limit} devices. Remove an old device or upgrade to continue.', + + 'my.language.title': 'Language', + 'my.language.desc': 'Choose your preferred UI language. This applies to explain text and context menus.', + 'my.language.current': 'Current language', + 'my.language.option.en': 'English', + 'my.language.option.zh': '中文', + 'my.language.option.ja': '日本語', + 'my.language.option.es': 'Español', + 'my.language.option.de': 'Deutsch', + 'my.language.selectLabel': 'Choose language', + 'my.language.saved': 'Language updated.', + 'my.settings.title': 'Settings', + 'my.settings.desc': 'Manage AI chat defaults here, then export runtime logs when you need troubleshooting details.', + 'my.settings.locationLabel': 'Export location', + 'my.settings.locationValue': 'Downloads folder when available; otherwise your home folder', + 'my.settings.limitLabel': 'Log limit', + 'my.settings.limitValue': '50 MB total', + 'my.settings.datasourceTimingTitle': 'Datasource timing diagnostics', + 'my.settings.datasourceTimingDesc': 'Write per-stage datasource timings to runtime logs for slow-request troubleshooting.', + 'my.settings.datasourceTimingOn': 'On', + 'my.settings.datasourceTimingOff': 'Off', + 'my.settings.datasourceTimingEnabled': 'Datasource timing diagnostics enabled.', + 'my.settings.datasourceTimingDisabled': 'Datasource timing diagnostics disabled.', + 'my.settings.datasourceTimingError': 'Failed to update datasource timing diagnostics: {message}', + 'my.settings.exportLogs': 'Export Logs', + 'my.settings.exportLogsDesc': 'Bundle runtime logs for troubleshooting review.', + 'my.settings.exporting': 'Exporting...', + 'my.settings.exportSuccess': 'Logs exported to {path}.', + 'my.settings.exportError': 'Failed to export logs: {message}', + + 'datasource.meta.databaseLabel': 'db: {value}', + 'datasource.type.d1': 'Cloudflare D1', + 'datasource.type.dynamodb': 'DynamoDB', + 'datasource.type.chromadb': 'ChromaDB', + 'datasource.type.unknown': 'Datasource', + 'datasource.list.title': 'Data Sources', + 'datasource.list.subtitle': 'Manage your data sources.', + 'datasource.list.searchPlaceholder': 'Search name, host, or type', + 'datasource.list.sortLabel': 'Sort', + 'datasource.list.sort.nameAsc': 'Name A-Z', + 'datasource.list.sort.nameDesc': 'Name Z-A', + 'datasource.list.sort.typeAsc': 'Type A-Z', + 'datasource.list.sort.status': 'Status', + 'datasource.list.testAll': 'Test All', + 'datasource.list.new': 'New Data Source', + 'datasource.list.empty': 'No data sources yet. Click "New Data Source" to add one.', + 'datasource.list.create': 'Create Data Source', + 'datasource.list.typeLogoAlt': '{type} logo', + 'datasource.list.copyEndpoint': 'Copy endpoint', + 'datasource.list.d1Endpoint': 'Cloudflare D1', + 'datasource.list.d1ReAuthentication': 'Re-authenticate', + 'datasource.list.d1ReAuthenticationLoading': 'Re-authenticating...', + 'datasource.list.d1ReAuthenticationSuccess': 'D1 re-authentication succeeded.', + 'datasource.list.d1ReAuthenticationNeedToken': 'Re-authentication failed: token is missing.', + 'datasource.list.d1ReAuthenticationAccountMismatch': 'Re-authentication failed: current datasource account is not available in OAuth session.', + 'datasource.list.dynamoReAuthentication': 'Re-authenticate', + 'datasource.list.dynamoReAuthenticationLoading': 'Re-authenticating...', + 'datasource.list.dynamoReAuthenticationSuccess': 'DynamoDB re-authentication succeeded.', + 'datasource.list.dynamoReAuthenticationNeedProfile': 'Re-authentication failed: DynamoDB profile is missing.', + 'datasource.list.dynamoReAuthenticationNeedRoleContext': 'Re-authentication failed: account/role context is missing.', + 'datasource.list.dynamoReAuthenticationNeedToken': 'Re-authentication failed: token is missing.', + 'datasource.list.checkedAt': 'Checked {time}', + 'datasource.list.copyError': 'Copy error', + 'datasource.list.openConsole': 'Open Console', + 'datasource.list.deleteTitle': 'Delete datasource', + 'datasource.list.noEndpointToCopy': 'No endpoint to copy.', + 'datasource.list.noErrorToCopy': 'No error to copy.', + 'datasource.list.deleted': 'Datasource deleted.', + + 'datasource.form.subtitle': 'Configure connection details and test connectivity.', + 'datasource.form.region': 'Region', + 'datasource.form.profileOptional': 'Profile (optional)', + 'datasource.form.endpointOptional': 'Endpoint (optional)', + 'datasource.form.endpointHint': 'Leave empty to use AWS endpoint for the selected region.', + 'datasource.form.useStaticCredentialsOptional': 'Use static credentials (optional)', + 'datasource.form.staticCredentialsRecommended': 'Recommended: use AWS profile / SSO. Static keys are stored locally.', + 'datasource.form.uploadCredentialsOptional': 'Upload credentials (optional)', + 'datasource.form.credentialsFileHint': 'Supports AWS shared credentials file (~/.aws/credentials) or IAM access keys CSV.', + 'datasource.form.accessKeyId': 'Access Key ID', + 'datasource.form.secretAccessKey': 'Secret Access Key', + 'datasource.form.sessionTokenOptional': 'Session Token (optional)', + 'datasource.form.mongoConnection': 'Mongo Connection', + 'datasource.form.mongoConnection.userpass': 'Username / Password', + 'datasource.form.mongoConnection.uri': 'Mongo URI', + 'datasource.form.mongoUri': 'Mongo URI', + 'datasource.form.sqlConnection': 'SQL Connection', + 'datasource.form.sqlConnection.userpass': 'Username / Password', + 'datasource.form.sqlConnection.uri': 'Direct URL', + 'datasource.form.secret.modeManual': 'Enter value', + 'datasource.form.secret.modeExisting': 'Reference existing secret', + 'datasource.form.secret.hint': 'FutrixData stores only a reference to your existing secret manager. The secret value is read on the backend when needed and never saved in FutrixData.', + 'datasource.form.secret.provider': 'Secret provider', + 'datasource.form.secret.providerPlaceholder': 'Select a provider', + 'datasource.form.secret.key': 'Secret path / key', + 'datasource.form.secret.keyPlaceholder': 'database/analytics/postgres', + 'datasource.form.secret.field': 'Field', + 'datasource.form.secret.fieldPlaceholder': 'password', + 'datasource.form.secret.version': 'Version (optional)', + 'datasource.form.secret.versionPlaceholder': 'latest', + 'datasource.form.sqlUri': 'Direct URL', + 'datasource.form.sqlUriPlaceholder.mysql': 'mysql://root:password@db.example.com:3306/mysql', + 'datasource.form.sqlUriPlaceholder.postgresql': 'postgresql://postgres:password@db.example.com:5432/postgres', + 'datasource.form.postgresSslEnabled': 'Enable SSL/TLS', + 'datasource.form.postgresSslEnabledHint': 'Use SSL/TLS for PostgreSQL. When disabled, Direct URL stays unchanged.', + 'datasource.form.postgresSslCertificate': 'SSL certificate (optional)', + 'datasource.form.postgresSslCertificateHint': 'Upload a PEM certificate. It is saved and reused for later connections.', + 'datasource.form.postgresSslCertificateSelected': 'Uploaded certificate: {name}', + 'datasource.form.postgresSslCertificateStored': 'A certificate is saved and will be reused.', + 'datasource.form.postgresSslCertificateStoredPrefix': 'Current datasource is using certificate:', + 'datasource.form.postgresSslCertificateStoredSuffix': 'Upload again to overwrite.', + 'datasource.form.postgresSslCertificatePathNotice': 'Certificate saved path: {path}', + 'datasource.form.postgresSslCertificateImported': 'Imported PostgreSQL certificate ({name}).', + 'datasource.form.mysqlSslEnabled': 'Enable SSL/TLS', + 'datasource.form.mysqlSslEnabledHint': 'Use SSL/TLS for MySQL. When disabled, Direct URL stays unchanged.', + 'datasource.form.mysqlSslCertificate': 'SSL certificate (optional)', + 'datasource.form.mysqlSslCertificateHint': 'Upload a PEM certificate. It is saved and reused for later connections.', + 'datasource.form.mysqlSslCertificateSelected': 'Uploaded certificate: {name}', + 'datasource.form.mysqlSslCertificateStored': 'A certificate is saved and will be reused.', + 'datasource.form.mysqlSslCertificateStoredPrefix': 'Current datasource is using certificate:', + 'datasource.form.mysqlSslCertificateStoredSuffix': 'Upload again to overwrite.', + 'datasource.form.mysqlSslCertificatePathNotice': 'Certificate saved path: {path}', + 'datasource.form.mysqlSslCertificateImported': 'Imported MySQL certificate ({name}).', + 'datasource.form.mongoSslEnabled': 'Enable SSL/TLS', + 'datasource.form.mongoSslEnabledHint': 'Use SSL/TLS for MongoDB. Certificate upload is optional.', + 'datasource.form.mongoSslCertificate': 'SSL certificate (optional)', + 'datasource.form.mongoSslCertificateHint': 'Upload a PEM certificate. It is saved and reused for later connections.', + 'datasource.form.mongoSslCertificateSelected': 'Uploaded certificate: {name}', + 'datasource.form.mongoSslCertificateStored': 'A certificate is saved and will be reused.', + 'datasource.form.mongoSslCertificateStoredPrefix': 'Current datasource is using certificate:', + 'datasource.form.mongoSslCertificateStoredSuffix': 'Upload again to overwrite.', + 'datasource.form.mongoSslCertificatePathNotice': 'Certificate saved path: {path}', + 'datasource.form.mongoSslCertificateImported': 'Imported MongoDB certificate ({name}).', + 'datasource.form.sslCertificateEmpty': 'Certificate file is empty.', + 'datasource.form.sslCertificateInvalidPem': 'Certificate must be a valid PEM file.', + 'datasource.form.sslCertificateDefaultName': 'certificate', + 'datasource.form.replicaSetOptional': 'Replica Set (optional)', + 'datasource.form.hosts': 'Hosts', + 'datasource.form.enableTls': 'Enable TLS', + 'datasource.form.authSource': 'Auth Source', + 'datasource.form.optionsJson': 'Options (JSON)', + 'datasource.form.chromadb.scheme': 'Scheme', + 'datasource.form.chromadb.tenant': 'Tenant', + 'datasource.form.chromadb.tenantPlaceholder': 'default_tenant', + 'datasource.form.chromadb.database': 'Database', + 'datasource.form.chromadb.databasePlaceholder': 'default_database', + 'datasource.form.chromadb.apiTokenOptional': 'API token (optional)', + 'datasource.form.chromadb.apiTokenHint': 'Sent as x-chroma-token when provided.', + 'datasource.form.d1.oauth': 'Cloudflare OAuth', + 'datasource.form.d1.oauthLogin': 'OAuth Login', + 'datasource.form.d1.oauthRelogin': 'Re-login', + 'datasource.form.d1.oauthLoading': 'Logging in...', + 'datasource.form.d1.oauthHint': 'Login requires Wrangler. If Wrangler is not installed, install it: npx wrangler.', + 'datasource.form.d1.wranglerInstallHint': 'Install Wrangler first: npm i -D wrangler@latest', + 'datasource.form.d1.oauthSuccess': 'Cloudflare OAuth connected.', + 'datasource.form.d1.oauthInvalidResponse': 'OAuth login succeeded but account/token is missing.', + 'datasource.form.d1.account': 'Account', + 'datasource.form.d1.accountPending': 'Account ID: not connected', + 'datasource.form.d1.accountIdDisplay': 'Account ID: {accountId}', + 'datasource.form.d1.selectAccount': 'Select an account', + 'datasource.form.d1.database': 'Database', + 'datasource.form.d1.selectDatabase': 'Select a database', + 'datasource.form.d1.createDatabaseOption': '+ Create new database', + 'datasource.form.d1.newDatabasePrompt': 'New D1 database name', + 'datasource.form.d1.newDatabaseNameLabel': 'New database name', + 'datasource.form.d1.newDatabaseNamePlaceholder': 'e.g. analytics', + 'datasource.form.d1.createDatabase': 'Create', + 'datasource.form.d1.cancelCreateDatabase': 'Cancel', + 'datasource.form.d1.createSuccess': 'Created D1 database: {name}', + 'datasource.form.d1.createInvalidResponse': 'Create database succeeded but response is missing id/name.', + 'datasource.form.d1.databaseOptionLabel': '{name} ({id})', + 'datasource.form.d1.loadingDatabases': 'Loading database list...', + 'datasource.form.d1.noDatabases': 'No D1 databases found. Use + Create new database.', + 'datasource.form.d1.supportDev': 'Support dev mode (optional)', + 'datasource.form.d1.supportDevHint': 'Enable this only when you want to run local D1 dev + migrations from a Worker project.', + 'datasource.form.d1.devProjectPath': 'Local Worker project path', + 'datasource.form.d1.devProjectPathPlaceholder': '/path/to/worker-project', + 'datasource.form.d1.devProjectPathHint': 'If empty, this datasource will run in remote mode only.', + 'datasource.form.dynamo.authMode': 'Authentication mode', + 'datasource.form.dynamo.authMode.sso': 'AWS SSO', + 'datasource.form.dynamo.authMode.profile': 'AWS profile / static keys', + 'datasource.form.dynamo.ssoProfile': 'SSO profile', + 'datasource.form.dynamo.ssoSelectProfile': 'Select an SSO profile', + 'datasource.form.dynamo.ssoLoadProfiles': 'Load SSO profiles', + 'datasource.form.dynamo.ssoLoadProfilesLoading': 'Loading SSO profiles...', + 'datasource.form.dynamo.ssoNoProfiles': 'No AWS SSO profiles found in ~/.aws/config.', + 'datasource.form.dynamo.ssoProfileOption': '{profile} ({region})', + 'datasource.form.dynamo.ssoConfigPathOptional': 'AWS config path (optional)', + 'datasource.form.dynamo.ssoConfigPathPlaceholder': 'Default: ~/.aws/config', + 'datasource.form.dynamo.ssoConfigPathHint': 'If the default path is missing, provide a custom config path (Windows path is supported).', + 'datasource.form.dynamo.ssoConfigApply': 'Apply', + 'datasource.form.dynamo.ssoConfigApplyLoading': 'Applying...', + 'datasource.form.dynamo.ssoConfigPathApplied': 'AWS config path applied.', + 'datasource.form.dynamo.ssoEndpointFromConfig': 'SSO endpoint (from AWS config)', + 'datasource.form.dynamo.ssoOauth': 'OAuth authorize', + 'datasource.form.dynamo.ssoOauthLoading': 'Authorizing with AWS SSO...', + 'datasource.form.dynamo.ssoOauthHint': 'One click runs AWS SSO login and fetches role credentials from profile/account/role settings.', + 'datasource.form.dynamo.ssoAuthorizedContext': 'Authorized account: {accountId}, role: {roleName}.', + 'datasource.form.dynamo.ssoLogin': 'Login with AWS SSO', + 'datasource.form.dynamo.ssoLoginLoading': 'Logging in with AWS SSO...', + 'datasource.form.dynamo.ssoLoginHint': 'Runs `aws sso login --profile ` and fetches available accounts.', + 'datasource.form.dynamo.ssoLoginSuccess': 'AWS SSO login succeeded.', + 'datasource.form.dynamo.ssoLoginInvalidResponse': 'AWS SSO login succeeded but access token is missing.', + 'datasource.form.dynamo.ssoAccount': 'AWS account', + 'datasource.form.dynamo.ssoSelectAccount': 'Select an AWS account', + 'datasource.form.dynamo.ssoAccountOption': '{name} ({id})', + 'datasource.form.dynamo.ssoRole': 'AWS role', + 'datasource.form.dynamo.ssoSelectRole': 'Select an AWS role', + 'datasource.form.dynamo.ssoAuthorize': 'Authorize role', + 'datasource.form.dynamo.ssoAuthorizeLoading': 'Authorizing role...', + 'datasource.form.dynamo.ssoAuthorizeSuccess': 'Role credentials loaded.', + 'datasource.form.dynamo.ssoAuthorizeInvalidResponse': 'Role authorization succeeded but credentials are missing.', + 'datasource.form.quickInstallDocker': 'Quick install (Docker)', + 'datasource.form.install.run': 'Run', + 'datasource.form.install.stopRemove': 'Stop & Remove', + 'datasource.form.install.connect': 'Connect', + 'datasource.form.testConnection': 'Test Connection', + 'datasource.form.title.edit': 'Edit Data Source', + 'datasource.form.title.create': 'New Data Source', + 'datasource.form.databasePlaceholder': 'database', + 'datasource.form.hint.mysql': 'Required: name, host, port (default 3306), username. Optional: database (defaults to mysql).', + 'datasource.form.hint.mysqlUri': 'MySQL Direct URL mode: provide full connection URL. You can enable SSL/TLS with the checkbox and optionally upload a certificate.', + 'datasource.form.hint.postgresql': 'Required: name, host, port (default 5432), username. Optional: database (defaults to postgres), SSL/TLS checkbox and certificate upload.', + 'datasource.form.hint.postgresqlUri': 'PostgreSQL Direct URL mode: provide full connection URL. You can enable SSL/TLS with the checkbox and optionally upload a certificate. If SSL/TLS is disabled and the server is non-SSL, include `?sslmode=disable` in Direct URL.', + 'datasource.form.hint.redis': 'Required: name, host, port. Password optional for ACL-enabled Redis.', + 'datasource.form.hint.elasticsearch': 'Elasticsearch: connect via host/port (default 9200). Console uses REST request syntax: `GET /_cat/indices?v` or `POST //_search` with JSON body.', + 'datasource.form.hint.dynamodb': 'DynamoDB: provide region (required). Recommended flow: choose profile then one-click OAuth authorization. Endpoint/profile/static credentials remain optional.', + 'datasource.form.hint.mongodb': 'MongoDB: choose Mongo URI (includes db/tls params in URI) or Username/Password. Replica set supports multiple hosts, optional replicaSet, and SSL/TLS certificate upload.', + 'datasource.form.hint.d1': 'Cloudflare D1: use OAuth login, then pick an existing database or create one in the dropdown.', + 'datasource.form.hint.chromadb': 'ChromaDB: connect via host/port (default 8000). Tenant/database default to default_tenant/default_database. Console uses read-only REST request syntax such as POST /collections//query with a JSON body.', + 'datasource.form.fileReaderUnsupported': 'File reader is not supported.', + 'datasource.form.awsCredentialsImported': 'Imported AWS credentials.', + 'datasource.form.awsCredentialsImportedWithProfile': 'Imported AWS credentials ({profile}).', + + 'history.title': 'History', + 'history.subtitle': 'Review execution history.', + 'history.searchPlaceholder': 'Search statement, datasource, or table', + 'history.clearFiltered': 'Delete Filtered Results', + 'history.clearFilters': 'Clear Filters', + 'history.loading': 'Loading history...', + 'history.loadFailed': 'Failed to load history: {message}', + 'history.empty': 'No history yet.', + 'history.clearFilteredTitle': 'Clear filtered history', + 'history.clearFilteredDesc': 'This will delete only entries matching current filters.', + 'history.filter.datasource': 'Datasource', + 'history.filter.target': 'Target', + 'history.filter.targets': 'Targets', + 'history.filter.database': 'Database', + 'history.clearedNotice': 'Cleared {count} entries.', + 'history.tab.console': 'Console History', + 'history.tab.agentAudit': 'Agent Audit', + 'history.agent.empty': 'No agent activity yet.', + 'history.agent.protocol.skill': 'Skill', + 'history.agent.protocol.mcp': 'MCP', + 'history.agent.protocol.cli': 'CLI', + 'history.agent.status.success': 'Success', + 'history.agent.status.error': 'Error', + 'history.agent.status.approval_required': 'Approval required', + 'history.agent.unknown': 'Unknown agent', + 'history.agent.rename': 'Rename', + 'history.agent.renamePlaceholder': 'Agent name', + 'history.agent.renameSaving': 'Saving…', + 'history.agent.tool': 'Tool', + 'history.agent.target': 'Target', + 'history.agent.summary': 'Summary', + 'history.agent.statement': 'Statement', + 'history.agent.filterLabel': 'Agent', + 'history.agent.filterAll': 'All agents', + 'history.agent.revokedBadge': 'Revoked', + 'history.agent.rejectionReason': 'Rejection reason', + 'history.agent.risk.title': 'Risk decision', + 'history.agent.risk.actionLabel': 'Action', + 'history.agent.risk.action.allow': 'Allow', + 'history.agent.risk.action.warn': 'Warn', + 'history.agent.risk.action.require_approval': 'Require approval', + 'history.agent.risk.action.block': 'Block', + 'history.agent.risk.levelLabel': 'Severity', + 'history.agent.risk.level.low': 'Low', + 'history.agent.risk.level.medium': 'Medium', + 'history.agent.risk.level.high': 'High', + 'history.agent.risk.ruleLabel': 'Matched rule', + 'history.agent.risk.reasonsLabel': 'Reason', + 'history.agent.risk.sourcePolicy': 'System policy', + 'history.agent.risk.viewRule': 'View rule', + + 'visualization.title': 'Visualization', + 'visualization.subtitle': 'Bring your data to life.', + 'visualization.clearHistory': 'Clear History', + 'visualization.savedCount': '{count} saved', + 'visualization.emptyHistory': 'No saved visualizations yet.', + 'visualization.untitled': 'Untitled', + 'visualization.emptyActive': 'No visualization selected. Save one from Console results, or pick from History.', + 'visualization.rows': '{count} rows', + 'visualization.query': 'Query', + 'visualization.settings': 'Visualization Settings', + 'visualization.chart': 'Chart', + 'visualization.dimension': 'Dimension', + 'visualization.metric': 'Metric', + 'visualization.aggregation': 'Aggregation', + 'visualization.unsupportedRenderer': 'Unsupported renderer: {renderer}', + + 'ai.panel.title': 'AI Settings', + 'ai.panel.subtitle': 'Customize AI configurations.', + 'ai.panel.providers': 'AI Configurations', + 'ai.panel.addProvider': 'Add Configuration', + 'ai.panel.empty': 'No AI configuration yet. Click "Add Configuration" to get started.', + 'ai.panel.noConnected': 'No connected AI configurations.', + 'ai.panel.modelNotSet': 'Model not set', + 'ai.panel.moreActions': 'More actions', + 'ai.panel.needsAttention': 'Needs attention', + 'ai.panel.noFailed': 'No failed AI configurations.', + 'ai.panel.deleteTitle': 'Delete AI Configuration', + 'ai.panel.deleted': 'AI configuration deleted', + 'ai.panel.tabChat': 'Chat Models', + 'ai.panel.tabEmbedding': 'Embedding Models', + 'ai.panel.embeddingSubtitle': 'Configure embedding models for vector search.', + 'ai.panel.embeddingEmpty': 'No embedding model configured. Add one to enable text-based vector search.', + 'ai.panel.embeddingDeleteTitle': 'Delete Embedding Configuration', + 'ai.panel.embeddingDeleted': 'Embedding configuration deleted', + 'ai.panel.embeddingNote': 'Embedding models convert text into vectors for similarity search in vector databases.', + 'ai.form.embeddingEndpointUrl': 'Embedding Endpoint URL', + 'ai.form.embeddingEndpointUrlPlaceholder': 'http://localhost:8901/v1/embeddings', + + 'ai.form.subtitle': 'Set credentials, endpoints, and models.', + 'ai.form.configurationName': 'Configuration Name', + 'ai.form.namePlaceholder': 'Production OpenAI', + 'ai.form.provider': 'Provider', + 'ai.form.model': 'Model', + 'ai.form.customModelPlaceholder': 'Custom model name', + 'ai.form.maxTokens': 'Max tokens', + 'ai.form.maxTokensPlaceholder': '2048 (default)', + 'ai.form.apiBaseUrl': 'API Base URL', + 'ai.form.baseUrlPlaceholder': 'https://api.openai.com/v1', + 'ai.form.apiKey': 'API Key', + 'ai.form.apiKeyPlaceholder': 'sk-...', + 'ai.form.hideApiKey': 'Hide API key', + 'ai.form.showApiKey': 'Show API key', + 'ai.form.customProviderNote': 'Custom providers use the OpenAI-compatible', + 'ai.form.testConnection': 'Test Connection', + 'ai.form.titleEdit': 'Edit AI Configuration', + 'ai.form.titleCreate': 'New AI Configuration', + + 'ai.preferences.title': 'AI Chat Preferences', + 'ai.preferences.subtitle': 'Control default open behavior and retention.', + 'ai.preferences.defaultOpen': 'Default open', + 'ai.preferences.retention': 'Conversation retention', + + 'ai.quickPrompt.removeContext': 'Remove context', + 'ai.quickPrompt.placeholder': 'Ask AI...', + 'ai.quickPrompt.send': 'Send', + + 'ai.sidebar.title': 'AI Chat', + 'ai.sidebar.newChat': 'New chat', + 'ai.sidebar.deleteConversation': 'Delete conversation', + 'ai.sidebar.approvalRequired': 'Approval required', + 'ai.sidebar.statementContext': 'Statement context', + 'ai.sidebar.label.datasource': 'Datasource', + 'ai.sidebar.label.database': 'Database', + 'ai.sidebar.label.risk': 'Risk', + 'ai.sidebar.label.trustLevel': 'Trust', + 'ai.sidebar.label.explain': 'EXPLAIN', + 'ai.sidebar.gateReason': 'Trust is set to {trust}, so {risk}-risk statements still require your approval. Change it in Risk rules → Trust levels.', + 'ai.sidebar.riskUnknown': 'unknown', + 'ai.sidebar.label.statement': 'Statement', + 'ai.sidebar.label.type': 'Type', + 'ai.sidebar.label.rows': 'Rows', + 'ai.sidebar.label.size': 'Size', + 'ai.sidebar.label.captured': 'Captured', + 'ai.sidebar.label.truncated': 'Truncated', + 'ai.sidebar.label.name': 'Name', + 'ai.sidebar.label.id': 'ID', + 'ai.sidebar.label.host': 'Host', + 'ai.sidebar.label.port': 'Port', + 'ai.sidebar.label.username': 'Username', + 'ai.sidebar.agent.chatmodel': 'Chat Model', + 'ai.sidebar.agent.deepagent': 'Deep Agent', + 'ai.sidebar.agent.planExecutor': 'Plan Executor', + 'ai.sidebar.agent.unknown': 'Agent', + 'ai.sidebar.plan.title': 'Plan', + 'ai.sidebar.plan.tabGroup': 'Plan view', + 'ai.sidebar.plan.tab.markdown': 'Markdown', + 'ai.sidebar.plan.tab.workflow': 'Workflow', + 'ai.sidebar.plan.stepDefault': 'Step {index}', + 'ai.sidebar.plan.empty': 'No plan steps available.', + 'ai.sidebar.plan.status.pending': 'Pending', + 'ai.sidebar.plan.status.inProgress': 'In progress', + 'ai.sidebar.plan.status.completed': 'Completed', + 'ai.sidebar.plan.status.blocked': 'Blocked', + 'ai.sidebar.explainUsesIndex': 'Uses index', + 'ai.sidebar.explainNoIndex': 'No index detected', + 'ai.sidebar.bytes': '{count} bytes', + 'ai.sidebar.yes': 'Yes', + 'ai.sidebar.no': 'No', + 'ai.sidebar.analyzeRiskNote': + "This will send a sampled set of result rows to your configured AI provider for data analysis. Approve only if you're OK with sharing this data.", + 'ai.sidebar.visualizationRiskNote': + "This will send a sampled set of result rows to your configured AI provider to generate data visualizations. Approve only if you're OK with sharing this data.", + 'ai.sidebar.reject': 'Reject', + 'ai.sidebar.approve': 'Approve', + 'ai.sidebar.executing': 'Executing…', + 'ai.sidebar.executingAction': 'Executing…', + 'ai.sidebar.removeContext': 'Remove context', + 'ai.sidebar.placeholder': 'Ask AI...', + 'ai.sidebar.voiceInput': 'Voice input', + 'ai.sidebar.voiceInputSoon': 'Voice input (coming soon)', + 'ai.sidebar.pause': 'Pause', + 'ai.sidebar.send': 'Send', + 'ai.sidebar.noProvider': 'No available AI configurations.', + 'ai.sidebar.providerFallback': 'AI Configuration', + 'ai.sidebar.pendingApprovalFirst': 'Please approve or reject the pending request first.', + 'ai.sidebar.requestFailed': 'AI request failed.', + 'ai.sidebar.approvalFailed': 'Approval failed.', + 'ai.contextGroup.current': 'Current', + 'ai.contextGroup.otherInDatasource': 'Other in datasource', + 'ai.contextGroup.otherDatasources': 'Other datasources', + + 'console.title': 'Console', + 'console.view.resizeEntitiesEditor': 'Resize entities and editor panels', + 'console.view.resizeEditorResults': 'Resize editor and results panels', + 'console.historyMini.title': 'History', + 'console.historyMini.more': 'More', + 'console.historyMini.empty': 'No history yet.', + 'console.resultsPanel.title': 'Results', + 'console.resultsPanel.expandedView': 'Expanded view', + 'console.resultsPanel.close': 'Close', + 'console.switchDatasource': 'Switch Datasource', + 'console.refreshEntities': 'Refresh Entities', + 'console.label.key': 'Key', + 'console.label.collection': 'Collection', + 'console.label.index': 'Index', + 'console.label.table': 'Table', + 'console.label.keyUpper': 'KEY', + 'console.label.collectionUpper': 'COLLECTION', + 'console.label.indexUpper': 'INDEX', + 'console.label.tableUpper': 'TABLE', + 'console.statementTitle.redis': 'Redis Command', + 'console.statementTitle.elastic': 'Elasticsearch Request', + 'console.statementTitle.dynamo': 'DynamoDB PartiQL', + 'console.statementTitle.chroma': 'ChromaDB Request', + 'console.statementTitle.default': 'Statement', + 'console.dynamo.controls.title': 'DynamoDB execution limits', + 'console.dynamo.controls.triggerLabel': 'Limits', + 'console.dynamo.controls.triggerAriaLabel': 'DynamoDB execution limits: {summary}', + 'console.dynamo.controls.summary': '{pageSize} · {maxReturnedRows} rows · {maxPages}p', + 'console.dynamo.controls.range': '{min}–{max}', + 'console.dynamo.controls.reset': 'Reset', + 'console.dynamo.controls.close': 'Close', + 'console.dynamo.controls.section.perRequest': 'Per PartiQL request', + 'console.dynamo.controls.section.budget': 'Total budget for this run', + 'console.dynamo.controls.footer': 'Hitting any limit stops execution and returns immediately.', + 'console.dynamo.controls.pageSize.label': 'Page', + 'console.dynamo.controls.pageSize.fullLabel': 'pageSize', + 'console.dynamo.controls.pageSize.help': 'The Limit sent on every ExecuteStatement call.', + 'console.dynamo.controls.maxReturnedRows.label': 'Rows', + 'console.dynamo.controls.maxReturnedRows.fullLabel': 'Desired rows to fetch', + 'console.dynamo.controls.maxReturnedRows.help': 'Auto-paginate until this many rows are collected. If max pages is reached first, returns whatever was gathered.', + 'console.dynamo.controls.maxPages.label': 'Pages', + 'console.dynamo.controls.maxPages.fullLabel': 'Max pages', + 'console.dynamo.controls.maxPages.range': '≥ 1', + 'console.dynamo.controls.maxPages.help': 'Maximum auto-pagination round-trips while striving for the desired row count. Risk policy caps the effective value by default.', + 'console.dynamo.status.effective': 'Limits: page {pageSize}, rows {maxReturnedRows}, pages {maxPages}', + 'console.dynamo.status.pageSize': 'Page size {pageSize}', + 'console.dynamo.status.maxPages': 'Max pages {maxPages}', + 'console.dynamo.status.maxEvaluatedItems': 'Max evaluated {maxEvaluatedItems}', + 'console.dynamo.status.pagesFetched': 'Fetched {pages} page(s)', + 'console.dynamo.status.clampedLimits': 'Clamped: {limits}', + 'console.dynamo.status.limitName.pageSize': 'page size', + 'console.dynamo.status.limitName.maxReturnedRows': 'row limit', + 'console.dynamo.status.limitName.maxPages': 'page limit', + 'console.dynamo.status.limitName.maxEvaluatedItems': 'evaluated item limit', + 'console.dynamo.status.stopReason.returned_row_limit': 'Stopped at row limit.', + 'console.dynamo.status.stopReason.page_limit': 'Stopped at page limit.', + 'console.dynamo.status.stopReason.evaluated_item_limit': 'Stopped at evaluated item limit.', + 'console.dynamo.status.stopReason.no_more_pages': 'All matching pages were read.', + 'console.dynamo.status.stopReason.empty_page_has_more': 'Stopped after an empty page with more data available.', + 'console.dynamo.status.stopReason.fallback': 'Stopped: {stopReason}', + 'console.dynamo.repair.title': 'Auto-repaired statement', + 'console.dynamo.repair.reason': 'PartiQL quoting was adjusted so the statement can run.', + 'console.dynamo.suggestion.title': 'Index suggestion', + 'console.dynamo.suggestion.reason': 'A matching secondary index can reduce the amount of data scanned.', + 'console.dynamo.hint.preview': 'Suggested statement', + 'console.dynamo.action.applyAndRun': 'Apply & run', + 'console.dynamo.action.replaceOnly': 'Replace only', + 'console.dynamo.action.execute': 'Execute', + 'console.dynamo.action.replace': 'Replace', + 'console.entityTitle.datasources': 'Datasources', + 'console.entityTitle.keys': 'Keys', + 'console.entityTitle.indices': 'Indices', + 'console.entityTitle.tables': 'Tables', + 'console.entityTitle.collections': 'Collections', + 'console.entityTitle.entities': 'Entities', + 'console.entityKind.generic': 'Generic', + 'console.entityKind.mongo': 'Mongo', + 'console.entityKind.redis': 'Redis', + 'console.entityKind.es': 'ES', + 'console.entityKind.ddb': 'DDB', + 'console.entityKind.chroma': 'Chroma', + 'console.entityKind.sql': 'SQL', + 'console.filter.pattern': 'Pattern', + 'console.filter.filter': 'Filter', + 'console.filter.placeholder.redis': 'user:*', + 'console.filter.placeholder.default': 'orders', + 'console.filter.hint.redis': 'Supports Redis glob patterns. Empty = all keys.', + 'console.filter.hint.server': 'Filter queries the server for large lists.', + 'console.filter.hint.local': 'Filter applies locally.', + 'console.empty.databases': 'No databases found.', + 'console.empty.keys': 'No keys found.', + 'console.empty.entities': 'No entities found.', + 'console.subtitle.selectDatasource': 'Select a datasource to begin.', + 'console.subtitle.dbNotSet': 'db: not set', + 'console.subtitle.tenant': 'tenant: {value}', + 'console.subtitle.region': 'region: {value}', + 'console.subtitle.endpoint': 'endpoint: {value}', + 'console.entities.mappings': 'Mappings', + 'console.entities.fields': 'Fields', + 'console.entities.stats': 'Stats', + 'console.entities.indexes': 'Indexes', + 'console.entities.chroma.id': 'Collection ID', + 'console.entities.chroma.dimension': 'Dimension', + 'console.entities.chroma.records': 'Records', + 'console.entities.chroma.metadata': 'Metadata', + 'console.entities.refresh': 'Refresh', + 'console.entities.databases': 'Databases', + 'console.entities.createDatabase': 'Create Database', + 'console.entities.loadingKeys': 'Loading keys...', + 'console.entities.collapse': 'Collapse', + 'console.entities.expand': 'Expand', + 'console.entities.collapseDetails': 'Collapse details', + 'console.entities.expandDetails': 'Expand details', + 'console.entities.loadingDetails': 'Loading details...', + 'console.entities.failed': 'Failed: {message}', + 'console.entities.details': 'Entity details', + 'console.entities.field': 'Field', + 'console.entities.column': 'Column', + 'console.entities.nullable': 'Nullable', + 'console.entities.default': 'Default', + 'console.entities.noMappings': 'No mappings.', + 'console.entities.noFields': 'No fields.', + 'console.entities.noStats': 'No stats.', + 'console.entities.noIndexes': 'No indexes.', + 'console.entities.tableKeys': 'Table keys', + 'console.entities.secondaryIndexes': 'Secondary indexes', + 'console.entities.indexColumns': 'Cols', + 'console.entities.noDetails': 'No details available.', + 'console.entities.loadingMore': 'Loading more...', + 'console.entities.createTableTitle': 'CREATE TABLE', + 'console.entities.createTableCopied': 'CREATE TABLE copied.', + 'console.entities.noCreateTableOutput': 'No CREATE TABLE output.', + 'console.entities.loading': 'Loading...', + 'console.entities.close': 'Close', + 'console.entities.view': 'View', + 'console.statement.noTargetUpper': 'NO TARGET', + 'console.statement.noTargetSelected': 'No target selected', + 'console.statement.tabs': 'Statement tabs', + 'console.statement.newTab': 'New statement tab', + 'console.statement.closeTab': 'Close tab', + 'console.statement.renameTab': 'Rename tab', + 'console.statement.scrollTabsLeft': 'Scroll tabs left', + 'console.statement.scrollTabsRight': 'Scroll tabs right', + 'console.statement.tabTitleWithDatasource': '{datasource} · {title}', + 'console.statement.executeStatement': 'Execute statement', + 'console.statement.typeToExecute': 'Type a statement to execute', + 'console.statement.execute': 'Execute', + 'console.statement.explainPlan': 'Explain execution plan', + 'console.statement.typeToExplain': 'Type a statement to explain', + 'console.statement.explain': 'Explain', + 'console.statement.beautify': 'Beautify', + 'console.statement.cursor': 'Ln {line}, Col {column}', + 'console.statement.currentTarget': 'Current target', + 'console.statement.placeholder': 'SQL, Mongo JSON, Redis command, Elasticsearch request, DynamoDB PartiQL, or ChromaDB request', + 'console.statement.runnableStatements': 'Runnable statements', + 'console.statement.executeAll': 'Execute All', + 'console.statement.explainAll': 'Explain All', + 'console.statement.executeStatementWithIndex': 'Execute statement {index}', + 'console.statement.analyzePostgres': 'Analyze', + 'console.elastic.dsl.queryBuilder': 'Query Builder', + 'console.elastic.dsl.subtitle': 'Refine search with filters or edit DSL directly.', + 'console.elastic.dsl.addFilter': 'Add Filter', + 'console.elastic.dsl.liveEditor': 'Live DSL Editor', + 'console.elastic.dsl.reset': 'Reset', + 'console.elastic.dsl.runSearch': 'Run Search', + 'console.elastic.dsl.dslTitle': 'Elasticsearch DSL', + 'console.elastic.dsl.syncActive': 'Sync Active', + 'console.elastic.dsl.validJson': 'Valid JSON', + 'console.elastic.dsl.invalidJson': 'Invalid JSON', + 'console.elastic.dsl.prettifyJson': 'Prettify JSON', + 'console.elastic.dsl.copyDsl': 'Copy DSL', + 'console.elastic.dsl.filterFieldPlaceholder': 'Field', + 'console.elastic.dsl.filterFieldSearchPlaceholder': 'Search fields...', + 'console.elastic.dsl.filterValuePlaceholder': 'Value', + 'console.elastic.dsl.filterValueTokenPlaceholder': 'Type a value and press Enter', + 'console.elastic.dsl.filterOperatorAria': 'Filter operator', + 'console.elastic.dsl.applyFilter': 'Apply', + 'console.elastic.dsl.updateFilter': 'Update', + 'console.elastic.dsl.noMatchingFields': 'No matching fields', + 'console.elastic.dsl.unsupportedClausesTitle': 'Builder has unsupported clauses', + 'console.elastic.dsl.unsupportedClausesBody': 'Query Builder only shows supported conditions. Edit complex clauses in the DSL editor.', + 'console.elastic.dsl.operatorNotEqual': '!=', + 'console.elastic.dsl.operatorIn': 'in', + 'console.elastic.dsl.operatorNotIn': 'not in', + 'console.elastic.dsl.operatorContains': 'contains', + 'console.elastic.dsl.operatorNotContains': 'not contains', + 'console.elastic.dsl.operatorMatchPhrase': 'match phrase', + 'console.elastic.dsl.operatorPrefix': 'prefix', + 'console.elastic.dsl.operatorWildcard': 'wildcard', + 'console.elastic.dsl.operatorRegexp': 'regexp', + 'console.elastic.dsl.operatorExists': 'exists', + 'console.elastic.dsl.operatorNotExists': 'not exists', + 'console.elastic.dsl.dslCopied': 'DSL copied.', + 'console.elastic.dsl.dslCopyFailed': 'Failed to copy DSL.', + 'console.chroma.dsl.queryBuilder': 'Query Builder', + 'console.chroma.dsl.subtitle': 'Refine search with filters or edit DSL directly.', + 'console.chroma.dsl.modeLabel': 'Chroma request mode', + 'console.chroma.dsl.modeGet': 'Collection Get', + 'console.chroma.dsl.modeQuery': 'Similarity Search', + 'console.chroma.dsl.currentCollection': 'Current Collection', + 'console.chroma.dsl.limit': 'Limit', + 'console.chroma.dsl.topK': 'Top K', + 'console.chroma.dsl.include': 'Include', + 'console.chroma.dsl.ids': 'IDs', + 'console.chroma.dsl.idsPlaceholder': '[\"doc-1\", \"doc-2\"]', + 'console.chroma.dsl.queryTexts': 'Query Texts', + 'console.chroma.dsl.queryTextsPlaceholder': '[\"how to reset password\"]', + 'console.chroma.dsl.primaryInput': 'Primary Search', + 'console.chroma.dsl.primaryQuery': 'Search Query', + 'console.chroma.dsl.queryInputPlaceholder': 'Type search text...', + 'console.chroma.dsl.queryInputHelper': 'Use plain text here. Add multiple lines only if you need multiple queries.', + 'console.chroma.dsl.idList': 'Document IDs', + 'console.chroma.dsl.idListPlaceholder': 'Filter by ID (optional): id-1, id-2, ...', + 'console.chroma.dsl.idListHelper': 'Paste one id per line, or separate them with commas.', + 'console.chroma.dsl.quickOptions': 'Result Options', + 'console.chroma.dsl.requestPreview': 'Request Preview', + 'console.chroma.dsl.showAdvanced': 'Show Advanced Filters', + 'console.chroma.dsl.hideAdvanced': 'Hide Advanced Filters', + 'console.chroma.dsl.showRawRequest': 'Show Raw Request', + 'console.chroma.dsl.hideRawRequest': 'Hide Raw Request', + 'console.chroma.dsl.modeGetDescription': 'Read a small set of documents by id when you already know what to fetch.', + 'console.chroma.dsl.modeQueryDescription': 'Run vector similarity search from plain text and decide whether distance should be shown.', + 'console.chroma.dsl.queryCount': '{count} query lines', + 'console.chroma.dsl.idCount': '{count} ids', + 'console.chroma.dsl.includeOption.documents': 'Show documents', + 'console.chroma.dsl.includeOption.metadatas': 'Show metadata', + 'console.chroma.dsl.includeOption.distances': 'Show distance', + 'console.chroma.dsl.includeOption.embeddings': 'Show embeddings', + 'console.chroma.dsl.where': 'Metadata Filter', + 'console.chroma.dsl.wherePlaceholder': '{"field": "value"}, {"field": {"$gte": 10}}', + 'console.chroma.dsl.whereHint': '$eq $ne $gt $gte $lt $lte $in $nin · $and $or', + 'console.chroma.dsl.whereDocument': 'Document Content Filter', + 'console.chroma.dsl.whereDocumentPlaceholder': '{"$contains": "keyword"}', + 'console.chroma.dsl.whereDocumentHint': '$contains $not_contains · $and $or', + 'console.chroma.dsl.invalidJsonHint': 'Invalid JSON — keys and strings require double quotes', + 'console.chroma.dsl.requestBody': 'Request Body', + 'console.chroma.dsl.validJson': 'Valid JSON', + 'console.chroma.dsl.invalidJson': 'Invalid JSON', + 'console.chroma.dsl.queryInputRequired': 'Add at least one query input', + 'console.chroma.dsl.queryInputHint': 'Similarity search needs at least one query text, or another query input added in the request body.', + 'console.chroma.dsl.reset': 'Reset', + 'console.chroma.dsl.runSearch': 'Run Search', + 'console.chroma.dsl.copyRequest': 'Copy Request', + 'console.chroma.dsl.bodyCopied': 'Request copied.', + 'console.chroma.dsl.bodyCopyFailed': 'Failed to copy request.', + 'console.chroma.dsl.vectorSearch': 'Vector', + 'console.chroma.dsl.textSearch': 'Text', + 'console.chroma.dsl.vectorSearchHint': 'ChromaDB REST API requires pre-computed embedding vectors for similarity search', + 'console.chroma.dsl.textSearchHint': 'Convert text to vectors using an embedding model, then search by similarity', + 'console.chroma.dsl.embeddingsPlaceholder': '[0.1, 0.2, 0.3, ...]', + 'console.chroma.dsl.textSearchPlaceholder': 'Enter search text...', + 'console.chroma.dsl.selectEmbeddingModel': 'Select embedding model', + 'console.chroma.dsl.noEmbeddingModels': 'No embedding models configured', + 'console.chroma.dsl.configureInSettings': 'Configure in AI Settings', + 'console.chroma.dsl.computing': 'Computing...', + 'console.chroma.dsl.maxDistance': 'Max Dist.', + 'console.chroma.dsl.maxDistancePlaceholder': '∞', + 'console.chroma.dsl.liveEditor': 'Live DSL Editor', + 'console.chroma.dsl.filters': 'Filters', + 'console.chroma.dsl.syncActive': 'Sync Active', + 'console.chroma.dsl.prettifyJson': 'Prettify JSON', + 'console.editor.formatStatement': 'Format statement', + 'console.d1.executionMode': 'D1 execution mode', + 'console.d1.executionMode.dev': 'dev', + 'console.d1.executionMode.remote': 'remote', + 'console.d1.deploy': 'Deploy', + 'console.d1.deploying': 'Deploying...', + 'console.d1.deploySuccess': 'D1 migrations deployed to remote.', + 'console.results.tabs': 'Result tabs', + 'console.results.resultOne': 'Result 1', + 'console.results.resultWithIndex': 'Result {index}', + 'console.results.filterField': 'filter field', + 'console.results.allFields': 'All fields', + 'console.results.filterButton': '+ Filter', + 'console.results.searchButton': 'Search', + 'console.results.clearAllFilters': 'Clear All', + 'console.results.filterAddPlaceholder': 'Add filter', + 'console.results.filterPanelTitle': 'CONDITION', + 'console.results.filterEditTitle': 'Edit Filter', + 'console.results.filterSearchColumns': 'Search columns...', + 'console.results.filterChangeField': 'Change field', + 'console.results.filterOperatorAria': 'Filter operator', + 'console.results.filterOperatorLabel': 'Operator', + 'console.results.filterValueLabel': 'Value', + 'console.results.filterValuePlaceholder': 'Value', + 'console.results.filterApply': 'Apply Filter', + 'console.results.filterUpdate': 'Update Filter', + 'console.results.filterNoFields': 'No filterable fields available for this target.', + 'console.results.filterNeedsTarget': 'Select a target table/collection before filtering.', + 'console.results.filterUnsupported': 'Current datasource does not support field filter query generation.', + 'console.results.filterOperatorEq': '=', + 'console.results.filterOperatorContains': 'contains', + 'console.results.filterOperatorGt': '>', + 'console.results.filterOperatorGte': '>=', + 'console.results.filterOperatorLt': '<', + 'console.results.filterOperatorLte': '<=', + 'console.results.filterOperatorIsNull': 'is null', + 'console.results.filterOperatorIsNotNull': 'is not null', + 'console.results.filterValueNull': 'NULL', + 'console.results.filterValueNotNull': 'NOT NULL', + 'console.results.export': 'Export', + 'console.results.exported': 'Exported to {path}.', + 'console.results.exportFailed': 'Export failed.', + 'console.results.visualization': 'Visualization', + 'console.results.expand': 'Expand', + 'console.results.prev': 'Prev', + 'console.results.next': 'Next', + 'console.results.pageLabel': 'Page {page}', + 'console.results.copyPage': 'Copy Page', + 'console.results.copyJson': 'Copy JSON', + 'console.results.itemLabel': 'Item', + 'console.results.loadMore': 'Load more', + 'console.results.loading': 'Loading...', + 'console.results.pageSize': 'Page size', + 'console.results.noOutput': 'No output.', + 'console.results.noResultsYet': 'No results yet.', + 'console.results.shortcutPrefix': 'Use', + 'console.results.shortcutSuffix': 'to execute quickly.', + 'console.results.prevPageAria': 'Previous page', + 'console.results.currentPageAria': 'Current page', + 'console.results.nextPageAria': 'Next page', + 'console.results.clickExecute': 'Click Execute to run mock query', + 'console.results.selectTargetExecute': 'Select target then Execute', + 'console.results.selectTargetExecuteWithPeriod': 'Select target then Execute.', + 'console.results.ready': 'Ready', + 'console.results.rowsFiltered': 'Rows: {filtered} / {total}', + 'console.results.rowsTotal': 'Rows: {total}', + 'console.results.rowsTotalWithElapsed': 'Rows: {total} | {ms}ms', + 'console.results.filterPlaceholderAll': 'Filter results...', + 'console.results.filterPlaceholderField': 'Filter {field}...', + 'console.results.filterNullToken': '[NULL]', + 'console.results.filterNullOption': 'Empty (NULL)', + 'console.results.showingZeroOfZero': 'Showing 0 of 0', + 'console.results.showingZeroOfTotal': 'Showing 0 of {total}', + 'console.results.showingRangeOfTotal': 'Showing 1-{visible} of {total}', + 'console.results.shortcutTip': 'Use Ctrl/Cmd + Enter to execute quickly.', + 'console.results.rowCopied': 'Row copied.', + 'console.results.pageCopied': 'Page copied.', + 'console.results.mongoResultsCopied': 'Mongo results copied.', + 'console.results.redisResultCopied': 'Redis result copied.', + 'console.results.resultsCopied': 'Results copied.', + 'console.results.rowDeleteTitle': 'Delete this row?', + 'console.results.rowUpdateTitle': 'Update this row?', + 'console.results.rowMutationSubtitle': 'Review the statement before it is executed.', + 'console.results.rowMutationTableLabel': 'Table', + 'console.results.rowMutationPkLabel': 'Primary key', + 'console.results.rowMutationColumnLabel': 'Column', + 'console.results.rowMutationCurrentLabel': 'Current', + 'console.results.rowMutationNewValueLabel': 'New value', + 'console.results.rowMutationPreviewLabel': 'Statement', + 'console.results.rowMutationSetNull': 'NULL', + 'console.results.rowMutationConfirmDelete': 'Delete row', + 'console.results.rowMutationConfirmUpdate': 'Apply update', + 'console.results.rowMutationCancel': 'Cancel', + 'console.results.rowDeleteAction': 'Delete row', + 'console.results.rowEditAction': 'Double-click to edit', + 'console.results.rowDeletedSuccess': 'Row deleted.', + 'console.results.rowUpdatedSuccess': 'Row updated.', + 'console.results.rowMutationFailed': 'Row operation failed: {error}', + 'console.results.rowMutationRiskBlocked': 'Row operation blocked by risk rules ({reasons}). Run the statement from the editor to review and approve.', + 'console.results.rowMutationNoRowsAffected': 'Row operation ran but affected 0 rows. The server state may have changed; refresh the result to verify.', + 'console.results.rowMissingPkValue': 'Row is missing a primary-key value for column {columns}; cannot proceed.', + 'console.results.rowMutationPkNotEditable': 'Primary-key column {column} cannot be edited inline.', + 'console.results.rowMutationColumnNotFound': 'Column {column} is not part of the table schema.', + 'console.results.rowMutationUnavailable': 'Quick row operations are only available for single-table SELECT queries with a primary key.', + 'console.elastic.results.documentResults': 'Document Results', + 'console.elastic.results.showingRange': 'Showing {from}-{to} of {total} hits', + 'console.elastic.results.rawJsonView': 'Raw JSON', + 'console.elastic.results.noFields': 'No fields.', + 'console.elastic.results.noHits': 'No matching hits.', + 'console.elastic.results.viewMetadata': 'View Metadata', + 'console.elastic.results.hideMetadata': 'Hide Metadata', + 'console.elastic.results.metadataTitle': 'Metadata', + 'console.elastic.results.collapseSidebar': 'Collapse', + 'console.elastic.results.expandSidebar': 'Expand', + 'console.elastic.results.fieldsCollapsed': 'AVAILABLE FIELDS', + 'console.elastic.results.searchHitsPlaceholder': 'Search visible hits...', + 'console.elastic.results.visibleCopied': 'Visible results copied.', + 'console.elastic.results.title': 'Document Results', + 'console.elastic.results.hitsMeta': '{total} hits in {ms}ms', + 'console.elastic.results.hitsMetaNoTime': '{total} hits', + 'console.elastic.results.resultWindowHint': 'Offset paging is limited to the first {limit} hits. Refine the query to reach deeper results.', + 'console.elastic.results.deepPagingDatasourceRequired': 'Select a datasource before changing pages.', + 'console.elastic.results.deepPagingUnavailable': 'Deep pagination is not available for this request.', + 'console.elastic.results.deepPagingBuildRequestFailed': 'Failed to prepare the deep pagination request.', + 'console.elastic.results.deepPagingPageUnreachable': 'The requested page could not be reached with deep pagination.', + 'console.elastic.results.deepPagingBuildFinalRequestFailed': 'Failed to prepare the requested page.', + 'console.elastic.results.summary': 'Showing {visible} of {total} documents', + 'console.elastic.results.availableFields': 'Available fields', + 'console.elastic.results.filterFieldsPlaceholder': 'Filter fields...', + 'console.elastic.results.listView': 'Table', + 'console.elastic.results.rawJson': 'Raw JSON', + 'console.elastic.results.expandAll': 'Expand All', + 'console.elastic.results.collapseAll': 'Collapse All', + 'console.elastic.results.copyRawValue': 'Copy Raw Value', + 'console.elastic.results.rawValueCopied': 'Raw value copied.', + 'console.elastic.results.documentSource': 'Document Source', + 'console.elastic.results.columnTimestamp': '@Timestamp', + 'console.elastic.results.columnId': '_id', + 'console.elastic.results.columnStatus': 'Status', + 'console.elastic.results.columnMessage': 'Message', + 'console.elastic.results.unknownType': 'Unknown', + 'console.elastic.results.unknownStatus': 'Unknown', + 'console.elastic.results.noMessage': '-', + 'console.chroma.results.title': 'Document Results', + 'console.chroma.results.hitsMeta': '{total} documents in {ms}ms', + 'console.chroma.results.hitsMetaNoTime': '{total} documents', + 'console.chroma.results.showingRange': 'Showing {from}-{to} of {total} documents', + 'console.chroma.results.listView': 'Table', + 'console.chroma.results.rawJson': 'Raw JSON', + 'console.chroma.results.expandAll': 'Expand All', + 'console.chroma.results.collapseAll': 'Collapse All', + 'console.chroma.results.copyRawValue': 'Copy Raw Value', + 'console.chroma.results.recordDetail': 'Record Detail', + 'console.chroma.results.metaId': 'ID', + 'console.chroma.results.metaDistance': 'Distance', + 'console.chroma.results.documentSource': 'Document Source', + 'console.chroma.results.viewMetadata': 'View Metadata', + 'console.chroma.results.hideMetadata': 'Hide Metadata', + 'console.lifecycle.noDatasources': 'No datasources available.', + 'console.lifecycle.noConnectedDatasources': 'No connected datasources available.', + 'console.history.entryDatasourceMismatch': 'History entry does not match current datasource.', + 'console.beautify.mongoUseDbMethod': 'Use db..(...) for beautify.', + 'console.beautify.mongoAddArguments': 'Add arguments before beautify.', + 'console.beautify.mongoInvalidStatement': 'Invalid Mongo statement. Fix syntax before beautify.', + 'console.beautify.sqlFailed': 'Failed to beautify SQL.', + 'console.mongo.promptDatabaseName': 'Database name', + 'console.suggestions.mongoHelpers': 'Mongo helpers', + 'console.suggestions.sqlHelpers': 'SQL helpers', + 'console.suggestions.dynamoHelpers': 'DynamoDB helpers', + 'console.redisDanger.title': 'Confirm Redis command', + 'console.redisDanger.subtitle': 'This command may block Redis or affect availability.', + 'console.redisDanger.detected': 'Detected: {value}', + 'console.redisDanger.highRisk': 'High risk', + + 'redis.inspector.title': 'Key Inspector', + 'redis.inspector.eyebrow.selected': 'Selected', + 'redis.inspector.eyebrow.none': 'No key selected', + 'redis.inspector.meta.keyDetailsPreview': 'Redis key details and preview.', + 'redis.inspector.meta.commandOutput': 'Redis command output.', + 'redis.inspector.meta.selectKeyToInspect': 'Select a key to inspect details.', + 'redis.inspector.newKey': 'New Key', + 'redis.inspector.copyKey': 'Copy Key', + 'redis.inspector.key': 'Key', + 'redis.inspector.tabs': 'Redis inspector tabs', + 'redis.inspector.previewTab': 'Preview', + 'redis.inspector.outputTab': 'Output', + 'redis.inspector.clearOutput': 'Clear output', + 'redis.inspector.firstItems': 'First {limit} {kind} item(s).', + 'redis.inspector.viewFull': 'View full', + 'redis.inspector.loadingFull': 'Loading full value...', + 'redis.inspector.failedLoadFull': 'Failed to load full value: {message}', + 'redis.inspector.noPreviewItems': 'No preview items.', + 'redis.inspector.selectKeyToPreview': 'Select a key to preview.', + 'redis.inspector.noPreviewAvailable': 'No preview available.', + 'redis.inspector.resultTabs': 'Result tabs', + 'redis.inspector.clearTabs': 'Clear tabs', + 'redis.inspector.commandOutput': 'Command Output', + 'redis.inspector.noOutput': 'No output.', + 'redis.inspector.keyCopied': 'Key copied.', + 'redis.inspector.showingAll': 'Showing all {count} item(s).', + 'redis.inspector.emptyString': 'Empty string value.', + 'redis.inspector.emptyHash': 'Empty hash — no fields.', + 'redis.inspector.emptyList': 'Empty list — no items.', + 'redis.inspector.emptySet': 'Empty set — no members.', + 'redis.inspector.emptyZset': 'Empty sorted set — no members.', + 'redis.inspector.emptyStream': 'Empty stream — no entries.', + 'redis.inspector.typeLong.string': 'String', + 'redis.inspector.typeLong.hash': 'Hash', + 'redis.inspector.typeLong.list': 'List', + 'redis.inspector.typeLong.set': 'Set', + 'redis.inspector.typeLong.zset': 'Sorted Set', + 'redis.inspector.typeLong.stream': 'Stream', + 'redis.inspector.chipJson': 'JSON', + 'redis.inspector.chipProtobuf': 'Protobuf', + 'redis.inspector.chipBinary': 'Binary', + 'redis.inspector.chipBinaryHint': 'Value contains non-text bytes; showing hex dump.', + 'redis.inspector.jsonToggleRaw': 'Raw', + 'redis.inspector.jsonTogglePretty': 'Pretty', + 'redis.inspector.viewModeLabel': 'View mode', + 'redis.inspector.viewModeAuto': 'Auto', + 'redis.inspector.viewModeText': 'Text', + 'redis.inspector.viewModeHex': 'Hex', + 'redis.inspector.hexTruncated': 'Hex view shows the first 64 KiB only — the full value is larger.', + 'redis.typeBadge.ariaLoading': 'Loading key type', + 'redis.typeBadge.ariaUnknown': 'Unknown key type', + + 'redis.shell.keysPanel': 'Keys Panel', + 'redis.shell.defaultName': 'Redis', + 'redis.shell.refreshKeys': 'Refresh keys', + 'redis.shell.searchKey': 'Search key', + 'redis.shell.searchKeys': 'Search keys', + 'redis.shell.keys': 'Keys', + 'redis.shell.scrollKeysLeft': 'Scroll keys left', + 'redis.shell.scrollKeysRight': 'Scroll keys right', + 'redis.shell.loadingKeys': 'Loading keys...', + 'redis.shell.noResults': 'No results', + 'redis.shell.keyInspectorAria': 'Key Inspector', + 'redis.shell.resourceUsageAria': 'Resource Usage', + 'redis.shell.node': 'Node', + 'redis.shell.memory': 'Memory', + 'redis.shell.cpu': 'CPU', + 'redis.shell.ttl': 'TTL (Time to Live)', + 'redis.shell.memoryUsage': 'Memory Usage', + 'redis.shell.encoding': 'Encoding', + 'redis.shell.metaTtl': 'TTL', + 'redis.shell.metaSize': 'SIZE', + 'redis.shell.metaEnc': 'ENC', + 'redis.shell.tab.value': 'Value', + 'redis.shell.tab.json': 'JSON', + 'redis.shell.tab.raw': 'Raw', + 'redis.shell.tab.protobuf': 'Protobuf', + 'redis.shell.copyContent': 'Copy Content', + 'redis.shell.expandView': 'Expand View', + 'redis.shell.schemaSource': 'Schema Source', + 'redis.shell.editor': 'Editor', + 'redis.shell.upload': 'Upload', + 'redis.shell.schemaPlaceholder': 'syntax = "proto3";\n\nmessage MyMessage {\n string id = 1;\n}', + 'redis.shell.messageType': 'Message Type', + 'redis.shell.noMessageTypeYet': 'No message type yet.', + 'redis.shell.schemaDecoding': 'Decoding value using {message} message from {source}.', + 'redis.shell.consoleCli': 'Console CLI', + 'redis.shell.consoleWindowControls': 'Console Window Controls', + 'redis.shell.enterCommandPlaceholder': 'enter command...', + 'redis.shell.enterCommandAria': 'Enter Redis command', + 'redis.shell.commandSuggestions': 'Command suggestions', + 'redis.shell.clipboardUnavailable': 'Clipboard is unavailable.', + 'redis.shell.commandCopied': 'Command copied.', + 'redis.shell.fileReadFailed': 'Failed to read file.', + 'redis.shell.fileReaderUnsupported': 'File reader is not supported.', + 'redis.shell.loadedSchema': 'Loaded {name}.', + 'redis.shell.schemaHintEditUpload': 'Edit or upload a .proto schema to decode this key.', + 'redis.shell.schemaNoMessageType': 'No message type found in current schema.', + 'redis.shell.notJsonValue': 'Not a JSON value.', + 'redis.protobuf.schema.label': 'Schema', + 'redis.protobuf.schema.placeholder': 'Select a schema…', + 'redis.protobuf.schema.empty': 'No schemas yet', + 'redis.protobuf.schema.search': 'Search schemas', + 'redis.protobuf.schema.uploadHint': 'Upload a .proto file to get started.', + 'redis.protobuf.message.label': 'Message type', + 'redis.protobuf.message.placeholder': 'Select a message…', + 'redis.protobuf.message.search': 'Search messages', + 'redis.protobuf.message.empty': 'No message types found in this schema.', + 'redis.protobuf.manage.open': 'Manage schemas', + 'redis.protobuf.manage.title': 'Manage protobuf schemas', + 'redis.protobuf.manage.upload': 'Upload .proto file', + 'redis.protobuf.manage.add': 'Add schema', + 'redis.protobuf.manage.rename': 'Rename', + 'redis.protobuf.manage.delete': 'Delete', + 'redis.protobuf.manage.confirmDelete': 'Delete "{name}"?', + 'redis.protobuf.manage.empty': 'No schemas yet. Upload a .proto file to get started.', + 'redis.protobuf.manage.close': 'Close', + 'redis.protobuf.manage.save': 'Save', + 'redis.protobuf.manage.cancel': 'Cancel', + 'redis.protobuf.manage.namePlaceholder': 'Schema name (e.g. user.proto)', + 'redis.protobuf.manage.contentPlaceholder': 'syntax = "proto3";\n\nmessage MyMessage {\n string id = 1;\n}', + 'redis.protobuf.manage.failed': 'Failed to save schema: {error}', + 'redis.protobuf.manage.deleteFailed': 'Failed to delete schema: {error}', + 'redis.protobuf.manage.loadFailed': 'Failed to load schemas: {error}', + 'redis.protobuf.manage.requireNameContent': 'Name and content are required.', + 'redis.protobuf.manage.fileTooLarge': 'File is too large (max {limit}).', + 'redis.protobuf.manage.fileReadFailed': 'Could not read file: {error}', + 'redis.protobuf.manage.closeIcon': 'Close dialog', + 'redis.protobuf.auto.high': 'Auto-detected: {message} (high confidence)', + 'redis.protobuf.auto.medium': 'Auto-detected: {message}', + 'redis.protobuf.auto.low': 'Possible match: {message}', + 'redis.protobuf.auto.tooLarge': 'Value too large for auto-detect (>64KB).', + 'redis.protobuf.savedImported': 'Imported existing schema text into managed schemas.', + 'redis.protobuf.importedName': 'Imported schema', + 'redis.protobuf.manage.contentLabel': '.proto content', + 'redis.protobuf.hint.selectSchema': 'Select a schema to decode this key.', + 'redis.protobuf.hint.selectMessage': 'Select a message type to decode this key.', + + 'visualization.builder.hint': 'Choose chart settings, then open in Visualization.', + 'visualization.builder.close': 'Close', + 'visualization.builder.noSimpleFields': 'No simple fields available for visualization.', + 'visualization.builder.chart': 'Chart', + 'visualization.builder.chart.bar': 'Bar', + 'visualization.builder.chart.line': 'Line', + 'visualization.builder.chart.pie': 'Pie', + 'visualization.builder.dimension': 'Dimension', + 'visualization.builder.metric': 'Metric', + 'visualization.builder.metric.count': 'Count', + 'visualization.builder.aggregation': 'Aggregation', + 'visualization.builder.aggregation.sum': 'Sum', + 'visualization.builder.aggregation.avg': 'Avg', + 'visualization.builder.aggregation.min': 'Min', + 'visualization.builder.aggregation.max': 'Max', + 'visualization.builder.errorNoData': 'No data to visualize. Pick another dimension/metric.', + 'visualization.builder.open': 'Open Visualization', + 'visualization.builder.titleCountBy': 'Count by {dimension}', + 'visualization.builder.titleAggregatedBy': '{aggregation}({metric}) by {dimension}', + + 'table.copyColumn': 'Copy', + 'table.copyRow': 'Copy row', + 'table.emptyRows': '0 rows.', + + 'jsonTree.collapseNode': 'collapse node', + 'jsonTree.expandNode': 'expand node', + 'jsonTree.summaryItems': '... {count} items ...', + 'jsonTree.summaryObjectFields': '... Object ({count} fields) ...', + + 'mongo.itemLabel': 'Doc', + 'mongo.moreFields': '+{count} more fields', + 'mongo.copyDocument': 'Copy document', + 'mongo.documentStructure': 'Document structure', + 'mongo.noDocumentDetails': 'No document details.', + 'mongo.rawJson': 'Raw JSON', + 'mongo.emptyDocuments': '0 documents.', + + 'theme.light': 'light', + 'theme.dark': 'dark', + 'theme.switchToTheme': 'Switch to {theme} theme', + + 'titleBar.ai': 'AI', + 'titleBar.minimize': 'Minimize', + 'titleBar.maximize': 'Maximize', + 'titleBar.close': 'Close', + + 'validation.nameRequired': 'Name is required.', + 'validation.modelRequired': 'Model is required.', + 'validation.typeRequired': 'Type is required.', + 'validation.hostRequired': 'Host is required.', + 'validation.portRequired': 'Port is required.', + 'validation.chromadbSchemeInvalid': 'ChromaDB scheme must be http or https.', + 'validation.regionRequired': 'Region is required.', + 'validation.usernameRequired': 'Username is required.', + 'validation.mongoHostsFormat': 'Mongo hosts must be in host:port format.', + 'validation.secretProviderRequired': 'Select a secret provider for the referenced secret.', + 'validation.secretKeyRequired': 'Secret path / key is required for the referenced secret.', + 'validation.d1OauthRequired': 'D1 OAuth is required. Click OAuth Login first.', + 'validation.d1ModeRequired': 'D1 mode is required.', + 'validation.d1DatabaseIdRequired': 'D1 databaseId is required.', + 'validation.d1DatabaseNameRequired': 'D1 databaseName is required.', + 'validation.d1BindingRequired': 'D1 binding is required in local mode.', + 'validation.d1AccountIdRequired': 'D1 accountId is required in cloud mode.', + 'validation.d1AuthModeInvalid': 'D1 auth mode must be wrangler or token.', + 'validation.d1ApiTokenRequired': 'D1 API token is required when auth mode is token.', + 'validation.d1CreateDatabaseNameRequired': 'D1 database name is required.', + 'validation.dynamoProfileRequired': 'DynamoDB profile is required.', + 'validation.dynamoSSOLoginRequired': 'Complete AWS SSO login first.', + 'validation.dynamoSSOAccountRequired': 'Select an AWS account.', + 'validation.dynamoSSORoleRequired': 'Select an AWS role.', + 'validation.dynamoSSOCredentialsRequired': 'AWS role credentials are required. Click OAuth authorize first.', + 'validation.sqlUriRequired': 'Direct URL is required.', + 'validation.optionsJson': 'Options must be valid JSON.', + 'validation.providerRequired': 'Provider is required.', + 'validation.apiKeyRequired': 'API key is required.', + 'validation.baseUrlRequiredForCustomProvider': 'Base URL is required for custom provider.', + + 'context.askAi': 'Ask with AI', + 'context.smartAssistant': 'Smart Assistant', + 'context.aiSuggestions': 'AI Suggestions', + 'context.customPlaceholder': 'Type custom question...', + 'context.enterToSend': 'Enter to send', + 'context.executeSelection': 'Execute Selection', + 'context.copySnippet': 'Copy Snippet', + 'context.viewHistory': 'View History', + 'context.redisCommandHelp': 'Redis command help', + 'context.redisCommandHelpDesc': 'Explain what this Redis command does and how to use it.', + 'context.explainLogic': 'Explain logic', + 'context.explainLogicDesc': 'Describe what this query retrieves in plain English.', + 'context.optimizePerformance': 'Optimize performance', + 'context.optimizePerformanceDesc': 'Suggest indexes and query optimizations.', + 'context.debugError': 'Debug error', + 'context.debugErrorDesc': 'Analyze potential syntax issues.', + 'context.execute': 'Execute', + 'context.copyCommand': 'Copy Command', + 'context.sendMessage': 'Send message', + + 'kb.title': 'My Knowledge Base', + 'kb.subtitle': 'Upload your data-related docs (schema notes, field explanations) so AI can better understand your data.', + 'kb.refresh': 'Refresh', + 'kb.newCategory': 'New Category', + 'kb.providerNotReady': 'No available AI configuration. Configure AI Settings to enable auto summaries after upload.', + 'kb.providerMessage.runtimeUnavailable': 'Wails runtime is not available. Run via Wails to use backend actions.', + 'kb.goAiSettings': 'Go to AI Settings', + + 'kb.categories.title': 'Categories', + 'kb.categories.countTitle': '{count} categories', + 'kb.categories.subtitle': 'Organize uploaded files by topic/scope.', + 'kb.categories.new': 'New', + 'kb.categories.empty': 'No categories yet. Create one to start uploading.', + + 'kb.selectCategory': 'Select a category to view files.', + 'kb.unknown': '-', + 'kb.scope.label': 'Scope:', + 'kb.edit': 'Edit', + 'kb.delete': 'Delete', + + 'kb.upload.title': 'Upload files', + 'kb.upload.supported': 'Supported: PDF, Word (.docx), Markdown, Text.', + 'kb.upload.action': 'Upload', + 'kb.upload.none': 'No files selected.', + 'kb.upload.selected': '{count} file(s) selected.', + + 'kb.files.title': 'Files', + 'kb.files.subtitle': 'Parsed text is indexed for search_knowledge. AI summary is generated automatically after upload.', + 'kb.files.empty': 'No files in this category yet.', + 'kb.files.parse': 'Parse', + 'kb.files.summary': 'Summary', + + 'kb.dialog.newCategory': 'New category', + 'kb.dialog.editCategory': 'Edit category', + 'kb.dialog.categorySubtitle': 'Categories can be global or datasource-scoped (bind multiple sources).', + 'kb.dialog.name': 'Name', + 'kb.dialog.description': 'Description', + 'kb.dialog.scope': 'Scope', + 'kb.dialog.scopeAll': 'All (global)', + 'kb.dialog.scopeDatasource': 'Datasource', + 'kb.dialog.bindDatasources': 'Bind datasources', + 'kb.dialog.noDatasources': 'No datasources found. Create a datasource first.', + 'kb.dialog.cancel': 'Cancel', + 'kb.dialog.save': 'Save', + + 'kb.dialog.deleteCategoryTitle': 'Delete category', + 'kb.dialog.deleteCategorySubtitle': 'This deletes all files in this category.', + 'kb.dialog.deleteFileTitle': 'Delete file', + 'kb.dialog.deleteFileSubtitle': 'This action cannot be undone.', + + 'kb.notice.nameRequired': 'Name is required.', + 'kb.notice.scopeDatasourceRequired': 'Select at least one datasource for datasource scope.', + 'kb.notice.categoryCreated': 'Category created.', + 'kb.notice.categoryUpdated': 'Category updated.', + 'kb.notice.uploaded': 'Uploaded.', + 'kb.notice.categoryDeleted': 'Category deleted.', + 'kb.notice.fileDeleted': 'File deleted.', + + 'kb.file.readFailed': 'Failed to read file.', + 'kb.file.invalidEncoding': 'Invalid file encoding.', + + 'kb.scope.datasource.single': 'DS', + 'kb.scope.datasource.multiple': 'DS ×{count}', + 'kb.scope.all': 'All', + 'kb.scope.datasource.title': 'datasource ({ids})', + 'kb.scope.datasource.titleNoIds': 'datasource', + 'kb.scope.all.title': 'all', + + 'kb.summary.pendingProvider': 'AI summary pending (configure AI provider).', + 'kb.summary.queued': 'AI summary queued.', + 'kb.summary.failed': 'AI summary failed.', + 'kb.summary.failedWithMessage': 'AI summary failed: {message}', + 'kb.summary.skipped': 'AI summary skipped.', + 'kb.summary.skippedWithMessage': 'AI summary skipped: {message}', + + 'sensitivity.title': 'Field Sensitivity', + 'sensitivity.infoBanner': 'Classify your database fields by sensitivity level. Once classified, sensitive fields can be masked or hashed when data flows through MCP/Skill integrations to protect against accidental exposure.', + 'sensitivity.selectProvider': 'Select AI Provider', + 'sensitivity.customRules': 'Custom Classification Rules', + 'sensitivity.customRulesHint': 'Describe which fields are sensitive in your data. These rules are saved and reused across all datasources.', + 'sensitivity.customRulesPlaceholder': 'e.g. Fields containing "phone", "mobile", "wechat" are PII contact info. Fields ending with "_id" referencing users are identifiers. The "salary" and "bonus" columns are financial data.', + 'sensitivity.scan': 'Scan', + 'sensitivity.scanning': 'Scanning...', + 'sensitivity.scanComplete': 'Scan complete', + 'sensitivity.scanFailed': 'Scan failed: {message}', + 'sensitivity.customRulesLoadFailed': 'Failed to load custom rules. Please try again.', + 'sensitivity.noReport': 'No classification report yet. Select an AI provider and run a scan to classify fields.', + 'sensitivity.noSchema': 'No schema cache available — open the console for this datasource first.', + 'sensitivity.allEntitiesSkipped': 'All entities were skipped — no field details available. Open and describe tables in the console first.', + 'sensitivity.autoDescribing': 'Auto-describing table schemas before scan — this may take a moment.', + 'sensitivity.lastScanned': 'Last scanned: {time}', + 'sensitivity.entity': 'Entity', + 'sensitivity.field': 'Field', + 'sensitivity.level': 'Level', + 'sensitivity.category': 'Category', + 'sensitivity.reason': 'Reason', + 'sensitivity.source': 'Source', + 'sensitivity.confirm': 'Confirm', + 'sensitivity.override': 'Override', + 'sensitivity.level.critical': 'Critical', + 'sensitivity.level.high': 'High', + 'sensitivity.level.medium': 'Medium', + 'sensitivity.level.low': 'Low', + 'sensitivity.level.unconfirmed': 'Unconfirmed', + 'sensitivity.category.pii': 'PII', + 'sensitivity.category.credential': 'Credential', + 'sensitivity.category.financial': 'Financial', + 'sensitivity.category.behavioral': 'Behavioral', + 'sensitivity.category.medical': 'Medical', + 'sensitivity.category.location': 'Location', + 'sensitivity.category.contact': 'Contact', + 'sensitivity.category.identifier': 'Identifier', + 'sensitivity.category.none': 'None', + 'sensitivity.source.ai': 'AI', + 'sensitivity.source.agent': 'Agent', + 'sensitivity.source.manual': 'Manual', + 'sensitivity.mode': 'Mode', + 'sensitivity.mode.whitelist': 'Whitelist', + 'sensitivity.mode.blacklist': 'Blacklist', + 'sensitivity.mode.whitelistDesc': 'All fields assumed sensitive unless classified as low.', + 'sensitivity.mode.blacklistDesc': 'All fields assumed non-sensitive unless classified as medium or above.', + 'sensitivity.progress': '{scanned}/{total} entities', + 'sensitivity.fieldCount': '{count} fields', + 'sensitivity.deleteConfirm': 'Delete all classifications for this datasource?', + 'sensitivity.deleted': 'Classifications deleted.', + 'sensitivity.statusPending': 'Pending', + 'sensitivity.statusScanning': 'Scanning', + 'sensitivity.statusDone': 'Done', + 'sensitivity.statusSkipped': 'Skipped', + 'sensitivity.scanStatus': 'Status', + + 'sensitivityList.subtitle': 'Scan and classify field sensitivity levels across your datasources', + 'sensitivityList.noDatasources': 'No datasources available', + 'sensitivityList.scanned': 'Scanned', + 'sensitivityList.unscanned': 'Not scanned', + + 'my.sensitivity.title': 'Sensitivity Level Configuration', + 'my.sensitivity.desc': 'Configure sensitivity levels, definitions, and AI agent access range.', + 'my.sensitivity.accessSensitivity': 'AI Agent Access Sensitivity', + 'my.sensitivity.noRestriction': 'No restriction', + 'my.sensitivity.accessFrom': 'From', + 'my.sensitivity.accessTo': 'To', + 'my.sensitivity.examples': 'Add example...', + 'my.sensitivity.editExamples': 'Edit examples', + 'my.sensitivity.editExamplesPrompt': 'Edit examples (comma-separated):', + 'my.sensitivity.pickColor': 'Pick color', + 'my.sensitivity.examplesHint': 'These field name examples are provided as reference when AI classifies your schema.', + 'my.sensitivity.levelKey': 'Key', + 'my.sensitivity.levelName': 'Level name', + 'my.sensitivity.levelDesc': 'Level description', + 'my.sensitivity.addLevel': 'Add Level', + 'my.sensitivity.save': 'Save', + 'my.sensitivity.resetDefaults': 'Reset to Defaults', + 'my.sensitivity.saved': 'Sensitivity configuration saved.', + 'my.sensitivity.resetSuccess': 'Sensitivity levels reset to defaults.', + 'my.sensitivityScan.title': 'Sensitivity Scan', + 'my.sensitivityScan.desc': 'Select multiple datasources and run sensitivity classification in batch.', + 'my.sensitivityScan.aiConfig': 'AI Config', + 'my.sensitivityScan.noDatasources': 'No datasources found. Add a datasource first.', + 'my.sensitivityScan.selectAll': 'Select all', + 'my.sensitivityScan.startScan': 'Start Scan', + 'my.sensitivityScan.scanning': 'Scanning...', + 'my.sensitivityScan.stop': 'Stop', + 'my.sensitivityScan.queued': 'Queued', + 'my.sensitivityScan.completed': 'Completed', + 'my.sensitivityScan.failed': 'Failed', + 'my.sensitivityScan.retry': 'Retry', + 'sensitivity.levelDef.L1.name': 'Public', + 'sensitivity.levelDef.L1.desc': 'Non-sensitive operational data', + 'sensitivity.levelDef.L2.name': 'Internal', + 'sensitivity.levelDef.L2.desc': 'Internal identifiers and metadata', + 'sensitivity.levelDef.L3.name': 'Confidential', + 'sensitivity.levelDef.L3.desc': 'Indirect PII, behavioral and location data', + 'sensitivity.levelDef.L4.name': 'Sensitive', + 'sensitivity.levelDef.L4.desc': 'Direct PII, financial and medical data', + 'sensitivity.levelDef.L5.name': 'Critical', + 'sensitivity.levelDef.L5.desc': 'Credentials, payment instruments, and highly sensitive personal data', + + 'route.sensitivity': 'Field Sensitivity', + + 'riskRules.title': 'Risk Rules', + 'riskRules.subtitle': 'Configure risk assessment rules for all datasource types.', + 'riskRules.tabs.rules': 'Rules', + 'riskRules.tabs.trustLevels': 'Trust Levels', + 'sensitivity.schemaEgress.title': 'AI Schema Access', + 'sensitivity.schemaEgress.desc': 'Controls whether AI Chat tools, the ER-diagram generator, and the sensitivity scanner are allowed to send this datasource\'s schema metadata (table names, field names, types, comments, indexes) to your configured AI provider — and whether Skill/MCP-connected agents are allowed to read this datasource\'s schema metadata.', + 'sensitivity.schemaEgress.distinction.title': 'How is this different from result row masking?', + 'sensitivity.schemaEgress.distinction.body': 'Row masking only redacts query result values. Schema metadata (table and field names, comments, indexes) is a separate egress path — even if rows are masked, sending the schema still exposes business structure. This setting governs that path explicitly.', + 'sensitivity.schemaEgress.empty': 'No datasources configured yet.', + 'sensitivity.schemaEgress.lastSentAt': 'Last sent {time} · {status}', + 'sensitivity.schemaEgress.neverSent': 'No schema metadata has been sent yet.', + 'sensitivity.schemaEgress.consent.unset.label': 'Not set', + 'sensitivity.schemaEgress.consent.unset.desc': 'Default. Schema metadata sends are blocked until you explicitly allow them.', + 'sensitivity.schemaEgress.consent.allowed.label': 'Allowed', + 'sensitivity.schemaEgress.consent.allowed.desc': 'AI tools, ER generation, sensitivity scan, and Skill/MCP-connected agents may read or send schema metadata. Each access is logged in the audit table below.', + 'sensitivity.schemaEgress.consent.denied.label': 'Denied', + 'sensitivity.schemaEgress.consent.denied.desc': 'Same as Not set, but explicit. Useful when you want to remember you reviewed and refused this datasource.', + 'sensitivity.schemaEgress.consent.groupLabel': 'Schema egress consent for {name}', + 'sensitivity.schemaEgress.trigger.ai_chat_describe_entity': 'AI Chat · describe entity', + 'sensitivity.schemaEgress.trigger.ai_chat_list_entities': 'AI Chat · list entities', + 'sensitivity.schemaEgress.trigger.ai_chat_get_schema_knowledge': 'AI Chat · get schema knowledge', + 'sensitivity.schemaEgress.trigger.ai_chat_get_er_knowledge': 'AI Chat · get ER knowledge', + 'sensitivity.schemaEgress.trigger.schema_knowledge_er_generation': 'ER diagram generator', + 'sensitivity.schemaEgress.trigger.sensitivity_scan': 'Sensitivity scan', + 'sensitivity.schemaEgress.trigger.mcp_list_entities': 'Skill/MCP · list entities', + 'sensitivity.schemaEgress.trigger.mcp_describe_entity': 'Skill/MCP · describe entity', + 'sensitivity.schemaEgress.trigger.mcp_get_schema_knowledge': 'Skill/MCP · get schema knowledge', + 'sensitivity.schemaEgress.trigger.mcp_get_er_knowledge': 'Skill/MCP · get ER knowledge', + 'sensitivity.schemaEgress.auditTitle': 'Recent egress records', + 'sensitivity.schemaEgress.auditDesc': 'The 50 most recent decisions, including denied attempts. Persisted to data/history/schema-llm-audit.jsonl.', + 'sensitivity.schemaEgress.auditEmpty': 'No egress decisions recorded yet.', + 'sensitivity.schemaEgress.audit.time': 'Time', + 'sensitivity.schemaEgress.audit.datasource': 'Datasource', + 'sensitivity.schemaEgress.audit.trigger': 'Trigger', + 'sensitivity.schemaEgress.audit.status': 'Status', + 'sensitivity.schemaEgress.audit.entities': 'Entities', + 'sensitivity.schemaEgress.audit.fields': 'Fields', + 'sensitivity.schemaEgress.audit.provider': 'Provider · Model', + 'sensitivity.schemaEgress.status.allowed': 'Sent', + 'sensitivity.schemaEgress.status.denied': 'Denied', + 'sensitivity.schemaEgress.status.unknown': 'Unknown', + 'riskRules.trustLevels.title': 'Per-Datasource Trust Level', + 'riskRules.trustLevels.desc': 'Controls how much autonomy AI Chat, MCP/Skill, and CLI tools have when acting on each datasource. AI Chat can ask for approval; third-party Skill, MCP, and agent-key CLI calls are rejected when approval is required.', + 'riskRules.trustLevels.empty': 'No datasources configured yet.', + 'riskRules.trustLevels.warningDanger': 'Danger mode bypasses every approval and block rule on this datasource. Only pick it for dev or test data you can recreate from scratch.', + 'riskRules.trustLevels.confirmDanger': 'Switch "{name}" to Danger mode?\n\nDanger mode bypasses every approval and block rule on this datasource. AI, MCP, and CLI tools will auto-execute any statement — including destructive operations matched by block rules.\n\nOnly confirm if this datasource holds data you can recreate from scratch.', + 'riskRules.trustLevels.confirmDangerTitle': 'Switch to Danger mode?', + 'riskRules.trustLevels.confirmDangerBody': 'Danger mode bypasses every approval and block rule on this datasource. AI, MCP, and CLI tools will auto-execute any statement — including destructive operations matched by block rules.\n\nOnly confirm if this datasource holds data you can recreate from scratch.', + 'riskRules.trustLevels.confirmDangerOk': 'Enable Danger mode', + 'riskRules.trustLevels.confirmDangerCancel': 'Cancel', + 'riskRules.trustLevels.approval.label': 'Approval', + 'riskRules.trustLevels.approval.desc': 'Every statement requires explicit approval in AI Chat or the desktop console. Third-party agents are rejected instead of waiting for approval.', + 'riskRules.trustLevels.cautious.label': 'Cautious', + 'riskRules.trustLevels.cautious.desc': 'Default. Auto-runs only low-risk statements (typically reads with good plans). Writes and unknown statements require approval in AI Chat and are rejected for third-party agents.', + 'riskRules.trustLevels.trusted.label': 'Trusted', + 'riskRules.trustLevels.trusted.desc': 'Auto-runs low- and medium-risk statements; high-risk operations require approval in AI Chat and are rejected for third-party agents.', + 'riskRules.trustLevels.danger.label': 'Danger', + 'riskRules.trustLevels.danger.desc': 'Auto-runs everything, including statements matched by a block rule. Use only for expendable datasources.', + 'riskRules.trustLevels.legacyNotice.title': 'AI auto-execute settings have moved', + 'riskRules.trustLevels.legacyNotice.body': 'Your previous auto-execute risk levels ({levels}) were a global preference. They have been replaced by per-datasource trust levels. Pick a level for each datasource below so AI, MCP, and CLI tools behave the way you expect.', + 'riskRules.trustLevels.legacyNotice.bodyStrict': 'You previously disabled AI auto-execution entirely (no risk levels allowed to run). That global preference has been replaced by per-datasource trust levels. The default (Cautious) auto-runs low-risk reads — if you want to keep the old strict behavior, switch each datasource below to Approval.', + 'riskRules.trustLevels.legacyNotice.dismiss': 'Got it', + 'riskRules.builtinSection': 'Built-in Rules', + 'riskRules.userSection': 'Custom Rules', + 'riskRules.newRule': 'New Rule', + 'riskRules.import': 'Import', + 'riskRules.export': 'Export', + 'riskRules.filterAll': 'All', + 'riskRules.empty': 'No custom rules yet. Create your first rule to get started.', + 'riskRules.confirmDelete': 'Delete this rule?', + 'riskRules.enableRule': 'Enable rule', + 'riskRules.disableRule': 'Disable rule', + 'riskRules.autoManaged': 'Applied automatically', + 'riskRules.importTitle': 'Import Rules', + 'riskRules.importHint': 'Paste JSON array of rules below.', + 'riskRules.importTextareaLabel': 'Import rules JSON', + 'riskRules.importBtn': 'Import', + 'riskRules.importSuccess': 'Imported {count} rules.', + 'riskRules.importError': 'Invalid JSON format.', + 'riskRules.triggerLabel': 'Triggers when: ', + 'riskRules.exportTitle': 'Export Rules', + 'riskRules.exportHint': 'Copy the JSON below to save your custom rules.', + 'riskRules.exportTextareaLabel': 'Export rules JSON', + 'riskRules.action.block': 'Block', + 'riskRules.action.require_approval': 'Require Approval', + 'riskRules.action.warn': 'Warn', + 'riskRules.action.allow': 'Allow', + 'riskRules.action.block.desc': 'Reject execution', + 'riskRules.action.require_approval.desc': 'Ask in AI Chat; reject third-party agents', + 'riskRules.action.warn.desc': 'Confirm before run', + 'riskRules.action.allow.desc': 'Pass through', + 'riskRules.form.sectionAction': 'Action', + 'riskRules.form.sectionScope': 'Scope', + 'riskRules.form.sectionCondition': 'Condition', + 'riskRules.form.sectionThresholds': 'Query Safety Thresholds', + 'riskRules.form.sectionInfo': 'Description & Priority', + 'riskRules.form.editBuiltinTitle': 'Edit Built-in Rule {code}', + 'riskRules.form.builtinBehaviorTitle': 'What this rule checks', + 'riskRules.form.builtinThresholdsTitle': 'Editable thresholds', + 'riskRules.form.builtinReadonly': 'This built-in rule is read-only.', + 'riskRules.form.dsTypes': 'Datasource types', + 'riskRules.form.datasource': 'Specific datasource', + 'riskRules.form.datasourceAny': 'Any', + 'riskRules.form.entity': 'Target table / entity', + 'riskRules.form.entityHint': 'Select tables from a connected datasource, or type a name.', + 'riskRules.form.entityManual': 'Type manually', + 'riskRules.form.entityFromDs': 'Pick from datasource', + 'riskRules.form.entityLoading': 'Loading tables...', + 'riskRules.form.entityEmpty': 'No tables found.', + 'riskRules.form.entitySearch': 'Filter tables...', + 'riskRules.form.entityPickTitle': 'Select Tables / Entities', + 'riskRules.form.entitySelected': 'selected', + 'riskRules.form.entityDone': 'Done', + 'riskRules.form.entityManualPlaceholder': 'e.g. orders, public.users', + 'riskRules.form.selectAll': 'Select all', + 'riskRules.form.deselectAll': 'Deselect all', + 'riskRules.form.clearAll': 'Clear all', + 'riskRules.form.sqlCommands': 'Statement type', + 'riskRules.form.whereClause': 'WHERE clause', + 'riskRules.form.whereAny': 'Any', + 'riskRules.form.whereRequired': 'Must have WHERE', + 'riskRules.form.whereNone': 'Must NOT have WHERE', + 'riskRules.form.redisCategory': 'Command category', + 'riskRules.form.redisWildcard': '* (All commands)', + 'riskRules.form.redisWildcardHint': 'Matches every Redis command. Use with care.', + 'riskRules.form.redisSpecific': 'Or specific commands', + 'riskRules.form.redisSpecificHint': 'Comma-separated, e.g. DEL, SET, *', + 'riskRules.form.redisBrowseAll': 'Browse all…', + 'riskRules.form.redisPickTitle': 'Pick Redis commands', + 'riskRules.form.redisPickSearch': 'Search commands…', + 'riskRules.form.redisPickSelected': 'selected', + 'riskRules.form.redisPickLoading': 'Loading Redis command catalog…', + 'riskRules.form.redisPickClear': 'Clear', + 'riskRules.form.redisPickApply': 'Apply selection', + 'riskRules.form.keyPattern': 'Key pattern', + 'riskRules.form.keyPatternHint': '* matches any, e.g. session:*, user:*', + 'riskRules.form.mongoOps': 'Operation', + 'riskRules.form.esMethod': 'HTTP Method', + 'riskRules.form.esPath': 'Path contains', + 'riskRules.form.esPathHint': 'e.g. _search, _bulk, _delete_by_query', + 'riskRules.form.maxExaminedRows': 'Max examined rows', + 'riskRules.form.allowSafeSeqScan': 'Allow safe sequential scans', + 'riskRules.form.allowSafeSeqScanHint': 'Small-table scans without index are allowed if rows and cost are within thresholds.', + 'riskRules.form.seqScanRowsThreshold': 'Max scan rows', + 'riskRules.form.costThreshold': 'Cost threshold', + 'riskRules.form.maxJoinCount': 'Max join count', + 'riskRules.form.maxFullScans': 'Max full table scans', + 'riskRules.form.maxEstimatedJoinRows': 'Max estimated join rows', + 'riskRules.form.maxDynamoDBPages': 'Max DynamoDB pages', + 'riskRules.form.maxDynamoDBEvaluatedItems': 'Max DynamoDB evaluated items', + 'riskRules.form.ruleName': 'Rule name', + 'riskRules.form.ruleNameHint': 'A short description of what this rule does.', + 'riskRules.form.reason': 'Reason shown to user', + 'riskRules.form.reasonHint': 'This message appears in the confirmation dialog.', + 'riskRules.form.priority': 'Priority', + 'riskRules.form.priorityLow': 'Low', + 'riskRules.form.priorityMedium': 'Medium', + 'riskRules.form.priorityHigh': 'High', + 'riskRules.form.priorityHint': 'Higher priority rules take precedence.', + 'riskRules.form.preview': 'Rule preview', + 'riskRules.form.previewText': 'This rule will {action} execution of {commands} on {entity} in {dsTypes}.', + 'riskRules.form.saveError': 'Failed to save rule.', + 'riskRules.form.save': 'Save Rule', + 'riskRules.redis.write': 'Write (SET, DEL, HSET...)', + 'riskRules.redis.read': 'Read (GET, MGET, TYPE...)', + 'riskRules.redis.scan': 'Scan (KEYS, SCAN, HGETALL...)', + 'riskRules.redis.admin': 'Admin (CONFIG, CLIENT...)', + 'riskRules.redis.script': 'Scripting (EVAL, FCALL...)', + 'riskRules.builtin.PRB-001.title': 'View verification', + 'riskRules.builtin.PRB-001.summary': 'Checks whether a D1 view can be resolved and verified before the statement runs.', + 'riskRules.builtin.PRB-001.trigger': 'the target is a D1 view and the app cannot parse the view definition, inspect it, or confirm how the view expands in the execution plan.', + 'riskRules.builtin.PRB-002.title': 'Execution path verification', + 'riskRules.builtin.PRB-002.summary': 'Checks whether the app can trust the execution path returned by EXPLAIN or similar probe data.', + 'riskRules.builtin.PRB-002.trigger': 'EXPLAIN fails, returns no usable result, or claims to use an index without enough structural evidence to prove how the statement will run.', + 'riskRules.builtin.PRB-003.title': 'No index detected', + 'riskRules.builtin.PRB-003.summary': 'Checks whether the execution plan shows index usage, with a small-table exception for safe sequential scans.', + 'riskRules.builtin.PRB-003.trigger': 'the plan does not show index usage. Small-table sequential scans can still pass if safe sequential scan is enabled and both scan-row and cost thresholds stay within limits.', + 'riskRules.builtin.PRB-004.title': 'Wide scan detected', + 'riskRules.builtin.PRB-004.summary': 'Checks how many rows, keys, or documents the statement is expected to examine.', + 'riskRules.builtin.PRB-004.trigger': 'examined rows, keys, or documents exceed the configured maximum. This covers SQL, D1, and MongoDB probe results.', + 'riskRules.builtin.PRB-005.title': 'High-cost plan patterns', + 'riskRules.builtin.PRB-005.summary': 'Checks whether the execution plan contains join-heavy, scan-heavy, or otherwise expensive access patterns.', + 'riskRules.builtin.PRB-005.trigger': 'the plan shows too many joins, too many full scans, very large join estimates, or other costly patterns such as temporary tables, filesort, materialization, broad aggregation joins, or scan-heavy view expansion.', + 'riskRules.builtin.PRB-006.title': 'Metadata missing', + 'riskRules.builtin.PRB-006.summary': 'Checks whether required metadata is available before the app judges DynamoDB access risk.', + 'riskRules.builtin.PRB-006.trigger': 'table metadata needed for the risk check is unavailable.', + 'riskRules.builtin.PRB-007.title': 'Access path not verified', + 'riskRules.builtin.PRB-007.summary': 'Checks whether the app can verify the final access path from available DynamoDB metadata.', + 'riskRules.builtin.PRB-007.trigger': 'the app still cannot confirm the access path after reading available metadata.', + }, + zh: { + ...APP_CONSOLE_MESSAGES.zh, + 'common.count': '{count} 项', + 'common.test': '测试', + 'common.edit': '编辑', + 'common.delete': '删除', + 'common.cancel': '取消', + 'common.close': '关闭', + 'common.enabled': '已启用', + 'common.disabled': '已停用', + 'common.save': '保存', + 'common.clear': '清除', + 'common.copy': '复制', + 'common.back': '返回', + 'common.copied': '已复制。', + 'common.commandCopied': '命令已复制。', + 'common.copyFailed': '复制失败。', + 'common.clipboardUnavailable': '当前环境不可用剪贴板。', + 'common.name': '名称', + 'common.type': '类型', + 'common.host': '主机', + 'common.port': '端口', + 'common.username': '用户名', + 'common.password': '密码', + 'common.database': '数据库', + 'common.custom': '自定义', + 'common.optionalDefaultHint': '可选。留空将使用默认值。', + 'common.less': '收起', + 'common.more': '更多', + 'common.show': '显示', + 'common.hide': '隐藏', + 'common.cannotUndo': '此操作不可撤销。', + 'common.color.green': '绿色', + 'common.color.blue': '蓝色', + 'common.color.yellow': '黄色', + 'common.color.orange': '橙色', + 'common.color.red': '红色', + 'common.color.purple': '紫色', + 'common.color.pink': '粉色', + 'common.color.gray': '灰色', + + 'status.connected': '已连接', + 'status.failed': '失败', + 'status.testing': '测试中', + 'status.testingEllipsis': '测试中...', + 'status.unknown': '未知', + + 'nav.sources': '数据源', + 'nav.history': '历史', + 'nav.dataSensitivity': '数据敏感度分级', + 'nav.riskRules': '风控规则', + 'nav.aiSettings': 'AI 配置', + 'nav.my': '我的', + + 'route.datasources': '数据源', + 'route.datasourceCreate': '新建数据源', + 'route.datasourceEdit': '编辑数据源', + 'route.console': '控制台', + 'route.history': '历史', + 'route.sensitivityList': '数据敏感度分级', + 'route.riskRules': '风控规则', + 'route.riskRulesCreate': '新建风控规则', + 'route.riskRulesEdit': '编辑风控规则', + 'route.aiSettings': 'AI 配置', + 'route.aiSettingsCreate': '新建 AI 配置', + 'route.aiSettingsEdit': '编辑 AI 配置', + 'route.my': '我的', + 'route.default': '控制台', + 'auth.login.loadingTitle': '正在检查登录状态', + 'auth.login.loadingDescription': 'FutrixData 正在恢复这台设备上的会话,请稍候。', + 'auth.login.title': '登录 FutrixData', + 'auth.login.description': '登录后可同步账户、设备和 Pro 功能;未登录时仍可按 Free 套餐在本地使用。', + 'auth.login.start': '在浏览器中登录', + 'auth.login.starting': '启动中...', + 'auth.login.noBrowser': '在其他设备上登录', + 'auth.login.useCode': '使用短码登录', + 'auth.login.waitingTitle': '等待登录完成', + 'auth.login.waitingDescription': '请在浏览器中完成登录,然后返回此处。', + 'auth.login.manualHint': '或输入浏览器中的短码', + 'auth.login.urlLabel': '登录链接', + 'auth.login.codeLabel': '手动短码', + 'auth.login.codePlaceholder': 'A3F-K9M', + 'auth.login.submitCode': '提交', + 'auth.login.back': '返回', + 'auth.login.cancel': '取消', + 'auth.login.expired': '登录会话已过期,请重新尝试。', + 'auth.notice.signInForRiskRules': '请先登录,再新增、编辑、删除、导入或导出自定义风控规则。', + 'auth.notice.signInForSensitivityRules': '请先登录,再自定义脱敏规则。', + 'startupRecovery.eyebrow': '启动恢复', + 'startupRecovery.loadingTitle': '正在检查本地数据', + 'startupRecovery.loadingDescription': 'FutrixData 正在打开这台设备,请稍候。', + 'startupRecovery.title': 'FutrixData 无法打开本地数据', + 'startupRecovery.note': + '你的本地数据没有被删除。如果确认无法恢复,FutrixData 可以在你确认后把旧加密数据移到保留文件夹,然后用新的本地数据启动。', + 'startupRecovery.dataPath': '本地数据文件', + 'startupRecovery.versionInfo': '文件版本', + 'startupRecovery.versionValue': '写入版本:{writer};需要 {minimum} 或更新版本。', + 'startupRecovery.versionUnknown': '未知', + 'startupRecovery.retentionPath': '保留数据文件夹', + 'startupRecovery.confirmMoveAside': '我确认将旧加密数据移到保留文件夹,并让 FutrixData 在这台设备上重新开始。', + 'startupRecovery.retrying': '正在重试...', + 'startupRecovery.moving': '正在移动数据...', + 'startupRecovery.actionFailed': '操作未完成。请打开日志查看详情,或稍后重试。', + 'startupRecovery.action.retry': '重试', + 'startupRecovery.action.updateApp': '更新 FutrixData', + 'startupRecovery.action.openLogs': '打开日志', + 'startupRecovery.action.moveAside': '移走旧数据并重新开始', + 'startupRecovery.reason.app_too_old': '当前 FutrixData 版本太旧,无法读取新版本写入的本地数据。请更新后重试。', + 'startupRecovery.reason.keychain_unavailable': 'FutrixData 无法访问系统 keychain。请解锁 keychain 或重启设备后重试。', + 'startupRecovery.reason.key_mismatch': '本地加密数据无法用这台设备上的密钥打开。', + 'startupRecovery.reason.corrupt_file': '本地加密数据可能已经损坏或写入不完整。', + 'startupRecovery.reason.migration_failed': 'FutrixData 无法安全迁移本地数据,原始文件已保留在原位置。', + 'startupRecovery.reason.unknown': 'FutrixData 启动时无法打开本地数据。', + + 'skill.install.title': '设置 AI Agent 集成', + 'skill.install.subtitle': '安装 Skill 和 MCP 服务,让你的 AI Agent 能够安全可控的直连接你的数据中心。', + 'skill.install.alreadyInstalled': '已安装', + 'skill.install.notInstalled': '未安装', + 'skill.install.notDetected': '未检测到', + 'skill.install.skip': '跳过', + 'skill.install.install': '安装', + 'skill.install.installing': '安装中...', + 'skill.install.done': '完成', + 'skill.install.skill': 'Skill', + 'skill.install.mcp': 'MCP', + 'skill.install.codexSetupLabel': 'Codex 插件', + 'skill.install.codexSetupReady': '请先在 Codex 里安装 FutrixData 插件,然后保持这里的 MCP 已勾选,点击安装即可为 Codex 生成本地访问密钥。', + 'skill.install.codexSetupNotDetected': '请先打开一次 Codex,让本机生成 Codex 配置;在 Codex 安装 FutrixData 插件后,再回到这里授权 MCP 访问。', + 'skill.install.codexSetupAuthorized': 'Codex MCP 已授权。若刚安装了插件,请重启或刷新 Codex,让它加载 FutrixData 工具。', + 'skill.install.codexSetupSelect': '使用插件接入', + 'skill.agentApprovalPolicyNotice': '第三方 Agent 不能代替你审批 FutrixData 操作。通过 Skill、MCP 或 agent 访问密钥发起的 CLI 调用只要命中「需要审批」,都会被直接拒绝并写入 Agent 审计。', + 'skill.install.sensitivityGrantLabel': '同时允许这些 agent 进行敏感度分级', + 'skill.install.sensitivityGrantHint': '默认关闭。未授权时无法调用敏感度写入工具(自定义规则、保存/删除报告)。后续可在「我的」页面随时按 agent 取消或重新授权。', + 'skill.install.sensitivityGrantShort': '敏感度策略', + 'skill.install.datasourceGrantLabel': '同时允许这些 agent 添加数据源', + 'skill.install.datasourceGrantHint': '默认关闭。仅开放创建/添加数据源工具;agent 仍不能更新或删除数据源,也不能创建信任或危险级数据源。', + 'skill.install.datasourceGrantShort': '数据源创建', + 'skill.install.grantPartialTitle': '安装成功,但部分授权未生效', + 'skill.install.grantPartialHint': 'Agent 已安装,但所选的某项权限暂未生效。请在「我的」页面手动重试。', + 'skill.install.sensitivityGrantPartialTitle': '安装成功,但敏感度授权未生效', + 'skill.install.sensitivityGrantPartialHint': 'Agent 已安装但暂时无法进行敏感度分级。请在「我的」页面手动重新授权,或修复后再试。', + 'skill.manage.title': 'AI Skill', + 'skill.manage.desc': '管理 AI 编程助手的 FutrixData Skill。安装后可安全连接数据源,卸载后移除。', + 'skill.manage.install': '安装', + 'skill.manage.uninstall': '卸载', + 'skill.manage.installSuccess': '已为 {name} 安装 Skill。', + 'skill.manage.installError': '为 {name} 安装 Skill 失败:{message}', + 'skill.manage.uninstallSuccess': '已为 {name} 卸载 Skill。', + 'skill.manage.uninstallError': '为 {name} 卸载 Skill 失败:{message}', + 'skill.manage.identityTitle': 'Agent 身份', + 'skill.manage.agentNameLabel': '名称', + 'skill.manage.agentNamePlaceholder': 'agent-xxxx', + 'skill.manage.accessKeyLabel': '访问密钥', + 'skill.manage.copyKey': '复制', + 'skill.manage.keyCopied': '已复制 ✓', + 'skill.manage.copyFailed': '复制访问密钥失败,请从输入框中手动复制。', + 'skill.manage.hideKey': '隐藏', + 'skill.manage.rename': '保存', + 'skill.manage.renameSaving': '保存中…', + 'skill.manage.renameSuccess': '已将 agent 重命名为 {name}。', + 'skill.manage.renameError': '重命名 agent 失败:{message}', + 'skill.manage.revoke': '吊销访问', + 'skill.manage.unrevoke': '恢复访问', + 'skill.manage.revoked': '已吊销', + 'skill.manage.revokeSuccess': '已吊销 agent 访问权限。', + 'skill.manage.revokeError': '吊销 agent 访问权限失败:{message}', + 'skill.manage.unrevokeSuccess': '已恢复 agent 访问权限。', + 'skill.manage.unrevokeError': '恢复 agent 访问权限失败:{message}', + 'skill.manage.revokeConfirmTitle': '确认吊销 agent 访问权限?', + 'skill.manage.revokeConfirmBody': '吊销后该 agent 的访问密钥会立即失效,但审计历史保留,便于后续追溯。', + 'skill.manage.revokeConfirm': '确认吊销', + 'skill.manage.noIdentity': '先安装 Skill 或 MCP,系统会自动为该安装生成访问密钥。', + + 'skill.manage.manualSectionTitle': '手动安装的 Agent', + 'skill.manage.manualSectionDesc': '手动接入的 AI Agent(除上述 4 个预设之外)所生成的访问密钥。可在此处改名、复制或吊销。', + 'skill.manage.manualEmpty': '暂无手动接入的 Agent。点击"新建手动 Agent"即可生成一个。', + 'skill.manage.manualNewBtn': '新建手动 Agent', + 'skill.manage.manualNewSuccess': '已创建手动 Agent {name}。', + 'skill.manage.manualNewError': '创建手动 Agent 失败:{message}', + 'skill.manage.manualNewPrompt': '输入新手动 Agent 的名称(例如 zed-research)', + 'skill.manage.manualNewDefault': 'manual-agent', + 'skill.newManual.dialogTitle': '新建手动 Agent', + 'skill.newManual.stage1Desc': '为这个访问密钥起一个能在审计日志里识别的名称。', + 'skill.newManual.stage2Desc': '把下面的安装片段粘贴到任意 AI Agent 或 MCP 客户端,即可让 {name} 接入。', + 'skill.newManual.nameLabel': 'Agent 名称', + 'skill.newManual.namePlaceholder': '例如 zed-research', + 'skill.newManual.create': '创建 Agent', + 'skill.newManual.creating': '创建中…', + 'skill.newManual.cancel': '取消', + 'skill.newManual.done': '完成', + 'skill.newManual.errorPrefix': '创建失败:{message}', + 'skill.newManual.infoErrorPrefix': 'Agent 已创建,但加载安装片段失败:{message}', + 'skill.newManual.grantErrorPrefix': 'Agent 已创建,但敏感度授权未生效:{message}。请在「我的」页面手动重试。', + 'skill.newManual.sensitivityGrantErrorPrefix': '敏感度授权未生效:{message}。', + 'skill.newManual.datasourceGrantErrorPrefix': '数据源创建授权未生效:{message}。', + 'skill.newManual.sensitivityGrantLabel': '允许此 Agent 修改敏感度分级策略', + 'skill.newManual.sensitivityGrantHint': '默认关闭。敏感度写入工具(自定义规则、保存/删除报告)在授权前不可调用,可在管理界面随时调整。', + 'skill.newManual.datasourceGrantLabel': '允许此 Agent 添加数据源', + 'skill.newManual.datasourceGrantHint': '默认关闭。仅开放添加/创建数据源工具;更新/删除仍需审批,且禁止直接创建信任或危险级数据源。', + 'skill.manage.sensitivityGrantLabel': '敏感度策略', + 'skill.manage.sensitivityGrantOn': '已授权', + 'skill.manage.sensitivityGrantOff': '未授权', + 'skill.manage.sensitivityGrantHint': '控制此 Agent 是否可调用敏感度写入工具(自定义规则、保存/删除报告)。读取类工具不受影响。', + 'skill.manage.sensitivityGrantAllow': '授权', + 'skill.manage.sensitivityGrantRevoke': '撤销', + 'skill.manage.sensitivityGrantOnSuccess': '已授予 {name} 敏感度策略权限。', + 'skill.manage.sensitivityGrantOffSuccess': '已撤销 {name} 的敏感度策略权限。', + 'skill.manage.sensitivityGrantError': '更新敏感度授权失败:{message}', + 'skill.manage.datasourceGrantLabel': '数据源创建', + 'skill.manage.datasourceGrantOn': '已授权', + 'skill.manage.datasourceGrantOff': '未授权', + 'skill.manage.datasourceGrantHint': '控制此 Agent 是否可免审批调用添加/创建数据源工具。更新和删除仍保持锁定。', + 'skill.manage.datasourceGrantAllow': '授权', + 'skill.manage.datasourceGrantRevoke': '撤销', + 'skill.manage.datasourceGrantOnSuccess': '已授予 {name} 数据源创建权限。', + 'skill.manage.datasourceGrantOffSuccess': '已撤销 {name} 的数据源创建权限。', + 'skill.manage.datasourceGrantError': '更新数据源创建授权失败:{message}', + 'skill.manage.manualBadge': '手动', + 'skill.manage.manualPathLabel': '来源', + 'skill.manage.manualPathValue': '手动安装', + 'skill.manage.showInstall': '查看安装片段', + + 'mcp.manage.title': 'MCP 服务', + 'mcp.manage.desc': '安装 FutrixData MCP 服务,让 AI Agent 通过 MCP 协议直接操作你的数据源。', + 'mcp.manage.installSuccess': '已为 {name} 安装 MCP 服务。', + 'mcp.manage.installError': '为 {name} 安装 MCP 服务失败:{message}', + 'mcp.manage.uninstallSuccess': '已为 {name} 卸载 MCP 服务。', + 'mcp.manage.uninstallError': '为 {name} 卸载 MCP 服务失败:{message}', + 'mcp.manage.codexAuthorize': '授权 Codex', + 'mcp.manage.codexDisconnect': '断开 Codex', + 'mcp.manage.codexDetectedHint': 'Codex 插件使用这里的 MCP 授权:会写入 FutrixData MCP 配置,并绑定一个专属 Codex 访问密钥。请先在 Codex 安装插件,再在这里授权。', + 'mcp.manage.codexAuthorizedHint': 'Codex 已授权。若插件是在 FutrixData 已打开时安装的,请重启或刷新 Codex。', + 'mcp.manage.codexNotDetectedHint': '请先打开一次 Codex,让 FutrixData 能找到它的配置,然后回到这里授权插件。', + 'codex.connect.title': '授权 Codex?', + 'codex.connect.desc': 'FutrixData 的 Codex 插件正在请求本机访问权限。只有当你已经安装该插件,并希望 Codex 在这台设备上使用 FutrixData MCP 工具时,才确认授权。', + 'codex.connect.confirm': '授权 Codex', + 'codex.connect.authorizing': '授权中...', + 'codex.connect.cancel': '取消', + 'codex.connect.success': 'Codex 已授权。请刷新或重启 Codex,让 FutrixData 插件加载本机 MCP bridge。', + 'codex.connect.error': '授权 Codex 失败:{message}', + 'skill.manualInstall.button': '手动安装', + 'skill.manualInstall.title': '手动安装 — 适用于任意 AI Agent', + 'skill.manualInstall.subtitle': 'Skill 和 MCP 都是通用协议,不止 Claude/Cursor/Codex/OpenCode。把下面的内容复制到你使用的任何 AI Agent 或 MCP 客户端即可。', + 'skill.manualInstall.cliPathLabel': 'CLI 路径', + 'skill.manualInstall.skillHeading': 'Skill 文件', + 'skill.manualInstall.skillDesc': '把这段 Markdown 放进你 agent 的 skill / rule 目录。根据客户端的格式选择版本。', + 'skill.manualInstall.mcpHeading': 'MCP Server 配置', + 'skill.manualInstall.mcpDesc': '把下面任一片段粘贴到客户端的 MCP 配置中。CLI 的绝对路径已经按你本机填好。', + 'skill.manualInstall.suggestedPath': '路径', + 'skill.manualInstall.copy': '复制', + 'skill.manualInstall.copyContent': '复制内容', + 'skill.manualInstall.copied': '已复制 ✓', + 'skill.manualInstall.close': '关闭', + 'skill.manualInstall.loading': '加载中…', + 'skill.manualInstall.agentNameLabel': 'Agent 名称', + 'skill.manualInstall.agentNamePlaceholder': 'agent-xxxx', + 'skill.manualInstall.agentNameSaving': '正在保存名称…', + 'skill.manualInstall.boundAccessKey': '这组安装片段已经绑定到当前 agent 身份。', + 'skill.manualInstall.snippets.standard-json.label': '标准 MCP(JSON)', + 'skill.manualInstall.snippets.standard-json.notes': '适用于 Claude Code、Cursor、Windsurf、Continue、Zed 等绝大多数 MCP 客户端。合并到已有的 mcpServers 映射中即可。', + 'skill.manualInstall.snippets.codex-toml.label': 'Codex(TOML)', + 'skill.manualInstall.snippets.codex-toml.notes': '适用于 Codex 及任何使用 TOML MCP 段的客户端。', + 'skill.manualInstall.snippets.opencode-json.label': 'OpenCode(JSON)', + 'skill.manualInstall.snippets.opencode-json.notes': 'OpenCode 的格式略有不同:顶层 mcp 映射,command 为数组,且 type 为 "local"。', + + 'my.menu.label': '我的菜单', + 'my.menu.account': '账户', + 'my.menu.knowledgeBase': '知识库', + 'my.menu.language': '语言', + 'my.menu.skill': 'AI Skill & MCP', + 'my.menu.sensitivity': '敏感度', + 'my.menu.sensitivityConfig': '敏感度配置', + 'my.menu.sensitivityScan': '敏感度分级', + 'my.menu.settings': '设置', + 'my.menu.chooseHint': '请选择一个菜单项继续。', + + 'my.account.title': '账户', + 'my.account.desc': '查看当前设备状态,登录后可管理账户功能和当前会话。', + 'my.account.emailLabel': '邮箱', + 'my.account.planLabel': '套餐', + 'my.account.statusLabel': '状态', + 'my.account.statusValue.active': '生效中', + 'my.account.statusValue.proExpired': 'Pro 已过期', + 'my.account.statusValue.trial': '试用中', + 'my.account.expiresLabel': '到期时间', + 'my.account.expiredOnLabel': '过期时间', + 'my.account.trialExpiresLabel': '试用到期时间', + 'my.account.planExpiredBanner': 'Pro 已过期,当前已切换为 Free 套餐。续费 Pro 后可继续使用 Pro 功能。', + 'my.account.deviceLimitLabel': '设备上限', + 'my.account.deviceLimitValue': '最多 {limit} 台设备', + 'my.account.deviceLabel': '当前设备', + 'my.account.versionLabel': '版本', + 'my.account.versionFallback': '开发版本', + 'my.account.refreshDevices': '刷新设备', + 'my.account.logout': '退出登录', + 'my.account.devicesTitle': '活跃设备', + 'my.account.deviceUsage': '已使用 {used}/{limit} 台设备', + 'my.account.loadingDevices': '正在加载设备...', + 'my.account.noDevices': '暂无活跃设备。', + 'my.account.currentDevice': '当前设备', + 'my.account.unnamedDevice': '未命名设备', + 'my.account.unnamedDeviceOn': '未命名 {platform} 设备', + 'my.account.unknownPlatform': '未知系统', + 'my.account.removeDevice': '移除设备', + 'my.account.logoutSuccess': '当前设备已退出登录。', + 'my.account.logoutError': '退出登录失败:{message}', + 'my.account.loadDevicesError': '加载设备失败:{message}', + 'my.account.removeSuccess': '设备已移除。', + 'my.account.removeError': '移除设备失败:{message}', + 'my.account.update.availableBadge': '有新版本', + 'my.account.update.latestLabel': '最新版本', + 'my.account.update.releaseNotes': '更新说明', + 'my.account.update.updateNow': '立即更新', + 'my.account.update.checkButton': '检查更新', + 'my.account.update.checking': '正在检查...', + 'my.account.update.upToDate': '已是最新版本。', + 'my.account.update.signInHint': '登录后可检查更新。', + 'my.account.update.error': '检查更新失败:{message}', + 'my.account.update.openError': '无法打开下载页面:{message}', + 'my.account.update.availableNotice': '发现新版本 {latest}。', + 'my.account.update.dismiss': '稍后再说', + 'plan.name.free': 'Free', + 'plan.name.pro': 'Pro', + 'plan.name.trial': '试用版', + 'plan.notice.datasourceLimit': '当前 {plan} 套餐最多支持 {limit} 个数据源,升级到 Pro 后可继续添加。', + 'plan.notice.riskRules': '自定义风控规则不属于当前 {plan} 套餐,升级到 Pro 后才可新增、编辑、导入或导出。', + 'plan.notice.deviceLimit': '当前 {plan} 套餐最多支持 {limit} 台设备。请先移除旧设备,或升级后继续使用。', + + 'my.language.title': '语言', + 'my.language.desc': '选择你的界面语言。Explain 文案和右键菜单会同步切换。', + 'my.language.current': '当前语言', + 'my.language.option.en': 'English', + 'my.language.option.zh': '中文', + 'my.language.option.ja': '日本語', + 'my.language.option.es': 'Español', + 'my.language.option.de': 'Deutsch', + 'my.language.selectLabel': '选择语言', + 'my.language.saved': '语言已更新。', + 'my.settings.title': '设置', + 'my.settings.desc': '在这里管理 AI 对话默认行为,并在需要排查问题时导出运行日志。', + 'my.settings.locationLabel': '导出位置', + 'my.settings.locationValue': '优先导出到下载目录;若不存在则导出到主目录', + 'my.settings.limitLabel': '日志上限', + 'my.settings.limitValue': '总计 50 MB', + 'my.settings.datasourceTimingTitle': '数据源耗时诊断', + 'my.settings.datasourceTimingDesc': '将数据源请求和内部阶段耗时写入运行日志,便于排查慢请求。', + 'my.settings.datasourceTimingOn': '已开', + 'my.settings.datasourceTimingOff': '已关', + 'my.settings.datasourceTimingEnabled': '数据源耗时诊断已开启。', + 'my.settings.datasourceTimingDisabled': '数据源耗时诊断已关闭。', + 'my.settings.datasourceTimingError': '更新数据源耗时诊断失败:{message}', + 'my.settings.exportLogs': '导出日志', + 'my.settings.exportLogsDesc': '打包运行日志,便于后续排查分析。', + 'my.settings.exporting': '导出中...', + 'my.settings.exportSuccess': '日志已导出到 {path}。', + 'my.settings.exportError': '导出日志失败:{message}', + + 'datasource.meta.databaseLabel': 'db: {value}', + 'datasource.type.d1': 'Cloudflare D1', + 'datasource.type.dynamodb': 'DynamoDB', + 'datasource.type.chromadb': 'ChromaDB', + 'datasource.type.unknown': '数据源', + 'datasource.list.title': '数据源', + 'datasource.list.subtitle': '管理你的数据源', + 'datasource.list.searchPlaceholder': '按名称、主机或类型搜索', + 'datasource.list.sortLabel': '排序', + 'datasource.list.sort.nameAsc': '名称 A-Z', + 'datasource.list.sort.nameDesc': '名称 Z-A', + 'datasource.list.sort.typeAsc': '类型 A-Z', + 'datasource.list.sort.status': '状态', + 'datasource.list.testAll': '全部测试', + 'datasource.list.new': '新建数据源', + 'datasource.list.empty': '暂无数据源,点击“新建数据源”开始添加。', + 'datasource.list.create': '创建数据源', + 'datasource.list.typeLogoAlt': '{type} 图标', + 'datasource.list.copyEndpoint': '复制地址', + 'datasource.list.d1Endpoint': 'Cloudflare D1', + 'datasource.list.d1ReAuthentication': '重新授权', + 'datasource.list.d1ReAuthenticationLoading': '重新授权中...', + 'datasource.list.d1ReAuthenticationSuccess': 'D1 重新认证成功。', + 'datasource.list.d1ReAuthenticationNeedToken': '重新认证失败:未获取到 token。', + 'datasource.list.d1ReAuthenticationAccountMismatch': '重新认证失败:当前数据源账号不在 OAuth 会话的账号列表中。', + 'datasource.list.dynamoReAuthentication': '重新认证', + 'datasource.list.dynamoReAuthenticationLoading': '重新认证中...', + 'datasource.list.dynamoReAuthenticationSuccess': 'DynamoDB 重新认证成功。', + 'datasource.list.dynamoReAuthenticationNeedProfile': '重新认证失败:缺少 DynamoDB profile。', + 'datasource.list.dynamoReAuthenticationNeedRoleContext': '重新认证失败:缺少账号或角色信息。', + 'datasource.list.dynamoReAuthenticationNeedToken': '重新认证失败:未获取到 token。', + 'datasource.list.checkedAt': '检测于 {time}', + 'datasource.list.copyError': '复制错误', + 'datasource.list.openConsole': '打开控制台', + 'datasource.list.deleteTitle': '删除数据源', + 'datasource.list.noEndpointToCopy': '没有可复制的地址。', + 'datasource.list.noErrorToCopy': '没有可复制的错误信息。', + 'datasource.list.deleted': '数据源已删除。', + + 'datasource.form.subtitle': '配置连接信息并测试连通性。', + 'datasource.form.region': '区域', + 'datasource.form.profileOptional': 'Profile(可选)', + 'datasource.form.endpointOptional': 'Endpoint(可选)', + 'datasource.form.endpointHint': '留空时将使用所选区域的 AWS 默认地址。', + 'datasource.form.useStaticCredentialsOptional': '使用静态凭证(可选)', + 'datasource.form.staticCredentialsRecommended': '建议使用 AWS profile / SSO。静态密钥仅保存在本地。', + 'datasource.form.uploadCredentialsOptional': '上传凭证文件(可选)', + 'datasource.form.credentialsFileHint': '支持 AWS shared credentials 文件(~/.aws/credentials)或 IAM access keys CSV。', + 'datasource.form.accessKeyId': 'Access Key ID', + 'datasource.form.secretAccessKey': 'Secret Access Key', + 'datasource.form.sessionTokenOptional': 'Session Token(可选)', + 'datasource.form.mongoConnection': 'Mongo 连接方式', + 'datasource.form.mongoConnection.userpass': '用户名 / 密码', + 'datasource.form.mongoConnection.uri': 'Mongo URI', + 'datasource.form.mongoUri': 'Mongo URI', + 'datasource.form.sqlConnection': 'SQL 连接方式', + 'datasource.form.sqlConnection.userpass': '用户名 / 密码', + 'datasource.form.sqlConnection.uri': 'Direct URL', + 'datasource.form.secret.modeManual': '直接输入', + 'datasource.form.secret.modeExisting': '引用已有密钥', + 'datasource.form.secret.hint': 'FutrixData 仅保存对外部密钥系统的引用,密钥值在后端按需读取,绝不存储在 FutrixData 中。', + 'datasource.form.secret.provider': '密钥提供方', + 'datasource.form.secret.providerPlaceholder': '请选择提供方', + 'datasource.form.secret.key': '密钥路径 / Key', + 'datasource.form.secret.keyPlaceholder': 'database/analytics/postgres', + 'datasource.form.secret.field': '字段', + 'datasource.form.secret.fieldPlaceholder': 'password', + 'datasource.form.secret.version': '版本(可选)', + 'datasource.form.secret.versionPlaceholder': 'latest', + 'datasource.form.sqlUri': 'Direct URL', + 'datasource.form.sqlUriPlaceholder.mysql': 'mysql://root:password@db.example.com:3306/mysql', + 'datasource.form.sqlUriPlaceholder.postgresql': 'postgresql://postgres:password@db.example.com:5432/postgres', + 'datasource.form.postgresSslEnabled': '启用 SSL/TLS', + 'datasource.form.postgresSslEnabledHint': '控制 PostgreSQL 是否启用 SSL/TLS。关闭时不会改动 Direct URL。', + 'datasource.form.postgresSslCertificate': 'SSL 证书(可选)', + 'datasource.form.postgresSslCertificateHint': '上传 PEM 证书。系统会保存该证书,并在后续连接中复用。', + 'datasource.form.postgresSslCertificateSelected': '已上传证书:{name}', + 'datasource.form.postgresSslCertificateStored': '当前数据源已保存证书,后续连接会复用。', + 'datasource.form.postgresSslCertificateStoredPrefix': '当前数据源正在使用的证书:', + 'datasource.form.postgresSslCertificateStoredSuffix': '再次上传以覆盖。', + 'datasource.form.postgresSslCertificatePathNotice': '证书保存位置:{path}', + 'datasource.form.postgresSslCertificateImported': '已导入 PostgreSQL 证书({name})。', + 'datasource.form.mysqlSslEnabled': '启用 SSL/TLS', + 'datasource.form.mysqlSslEnabledHint': '控制 MySQL 是否启用 SSL/TLS。关闭时不会改动 Direct URL。', + 'datasource.form.mysqlSslCertificate': 'SSL 证书(可选)', + 'datasource.form.mysqlSslCertificateHint': '上传 PEM 证书。系统会保存该证书,并在后续连接中复用。', + 'datasource.form.mysqlSslCertificateSelected': '已上传证书:{name}', + 'datasource.form.mysqlSslCertificateStored': '当前数据源已保存证书,后续连接会复用。', + 'datasource.form.mysqlSslCertificateStoredPrefix': '当前数据源正在使用的证书:', + 'datasource.form.mysqlSslCertificateStoredSuffix': '再次上传以覆盖。', + 'datasource.form.mysqlSslCertificatePathNotice': '证书保存位置:{path}', + 'datasource.form.mysqlSslCertificateImported': '已导入 MySQL 证书({name})。', + 'datasource.form.mongoSslEnabled': '启用 SSL/TLS', + 'datasource.form.mongoSslEnabledHint': '控制 MongoDB 是否启用 SSL/TLS。证书上传为可选。', + 'datasource.form.mongoSslCertificate': 'SSL 证书(可选)', + 'datasource.form.mongoSslCertificateHint': '上传 PEM 证书。系统会保存该证书,并在后续连接中复用。', + 'datasource.form.mongoSslCertificateSelected': '已上传证书:{name}', + 'datasource.form.mongoSslCertificateStored': '当前数据源已保存证书,后续连接会复用。', + 'datasource.form.mongoSslCertificateStoredPrefix': '当前数据源正在使用的证书:', + 'datasource.form.mongoSslCertificateStoredSuffix': '再次上传以覆盖。', + 'datasource.form.mongoSslCertificatePathNotice': '证书保存位置:{path}', + 'datasource.form.mongoSslCertificateImported': '已导入 MongoDB 证书({name})。', + 'datasource.form.sslCertificateEmpty': '证书文件为空。', + 'datasource.form.sslCertificateInvalidPem': '证书文件必须为有效 PEM 格式。', + 'datasource.form.sslCertificateDefaultName': 'certificate', + 'datasource.form.replicaSetOptional': '副本集(可选)', + 'datasource.form.hosts': '主机列表', + 'datasource.form.enableTls': '启用 TLS', + 'datasource.form.authSource': '认证库', + 'datasource.form.optionsJson': '扩展参数(JSON)', + 'datasource.form.chromadb.scheme': '协议', + 'datasource.form.chromadb.tenant': '租户', + 'datasource.form.chromadb.tenantPlaceholder': 'default_tenant', + 'datasource.form.chromadb.database': '数据库', + 'datasource.form.chromadb.databasePlaceholder': 'default_database', + 'datasource.form.chromadb.apiTokenOptional': 'API token(可选)', + 'datasource.form.chromadb.apiTokenHint': '填写后会作为 x-chroma-token 发送。', + 'datasource.form.d1.oauth': 'Cloudflare OAuth', + 'datasource.form.d1.oauthLogin': 'OAuth 登录', + 'datasource.form.d1.oauthRelogin': '重新登录', + 'datasource.form.d1.oauthLoading': '登录中...', + 'datasource.form.d1.oauthHint': 'Login 需要wrangler,如果未安装wrangler,请安装:npx wrangler', + 'datasource.form.d1.wranglerInstallHint': '请先安装wrangler:npm i -D wrangler@latest', + 'datasource.form.d1.oauthSuccess': 'Cloudflare OAuth 已连接。', + 'datasource.form.d1.oauthInvalidResponse': 'OAuth 登录成功,但没有拿到 account/token。', + 'datasource.form.d1.account': 'Account', + 'datasource.form.d1.accountPending': 'Account ID:未连接', + 'datasource.form.d1.accountIdDisplay': 'Account ID:{accountId}', + 'datasource.form.d1.selectAccount': '请选择账号', + 'datasource.form.d1.database': '数据库', + 'datasource.form.d1.selectDatabase': '请选择数据库', + 'datasource.form.d1.createDatabaseOption': '+ 新建数据库', + 'datasource.form.d1.newDatabasePrompt': '新建 D1 数据库名称', + 'datasource.form.d1.newDatabaseNameLabel': '新数据库名称', + 'datasource.form.d1.newDatabaseNamePlaceholder': '例如 analytics', + 'datasource.form.d1.createDatabase': '创建', + 'datasource.form.d1.cancelCreateDatabase': '取消', + 'datasource.form.d1.createSuccess': '已创建 D1 数据库:{name}', + 'datasource.form.d1.createInvalidResponse': '创建数据库成功,但响应缺少 id/name。', + 'datasource.form.d1.databaseOptionLabel': '{name} ({id})', + 'datasource.form.d1.loadingDatabases': '数据库列表加载中...', + 'datasource.form.d1.noDatabases': '未找到 D1 数据库,可使用 + 新建数据库。', + 'datasource.form.d1.supportDev': '支持 dev 模式(可选)', + 'datasource.form.d1.supportDevHint': '仅在需要基于 Worker 本地项目执行 D1 dev 与 migration 时开启。', + 'datasource.form.d1.devProjectPath': '本地 Worker 项目路径', + 'datasource.form.d1.devProjectPathPlaceholder': '/path/to/worker-project', + 'datasource.form.d1.devProjectPathHint': '留空则该数据源仅支持 remote 模式。', + 'datasource.form.dynamo.authMode': '认证方式', + 'datasource.form.dynamo.authMode.sso': 'AWS SSO', + 'datasource.form.dynamo.authMode.profile': 'AWS profile / 静态密钥', + 'datasource.form.dynamo.ssoProfile': 'SSO Profile', + 'datasource.form.dynamo.ssoSelectProfile': '请选择 SSO Profile', + 'datasource.form.dynamo.ssoLoadProfiles': '加载 SSO Profile', + 'datasource.form.dynamo.ssoLoadProfilesLoading': '加载 SSO Profile 中...', + 'datasource.form.dynamo.ssoNoProfiles': '在 ~/.aws/config 中未找到 AWS SSO Profile。', + 'datasource.form.dynamo.ssoProfileOption': '{profile}({region})', + 'datasource.form.dynamo.ssoConfigPathOptional': 'AWS config 路径(可选)', + 'datasource.form.dynamo.ssoConfigPathPlaceholder': '默认:~/.aws/config', + 'datasource.form.dynamo.ssoConfigPathHint': '默认路径不存在时,可填写自定义 config 路径(支持 Windows 路径)。', + 'datasource.form.dynamo.ssoConfigApply': '应用', + 'datasource.form.dynamo.ssoConfigApplyLoading': '应用中...', + 'datasource.form.dynamo.ssoConfigPathApplied': 'AWS config 路径已应用。', + 'datasource.form.dynamo.ssoEndpointFromConfig': 'SSO Endpoint(来自 AWS config)', + 'datasource.form.dynamo.ssoOauth': 'OAuth 授权', + 'datasource.form.dynamo.ssoOauthLoading': 'AWS SSO 授权中...', + 'datasource.form.dynamo.ssoOauthHint': '单击后会自动执行 AWS SSO 登录,并按 profile/account/role 配置获取角色凭证。', + 'datasource.form.dynamo.ssoAuthorizedContext': '已授权账号:{accountId},角色:{roleName}。', + 'datasource.form.dynamo.ssoLogin': 'AWS SSO 登录', + 'datasource.form.dynamo.ssoLoginLoading': 'AWS SSO 登录中...', + 'datasource.form.dynamo.ssoLoginHint': '会执行 `aws sso login --profile ` 并拉取可用账号列表。', + 'datasource.form.dynamo.ssoLoginSuccess': 'AWS SSO 登录成功。', + 'datasource.form.dynamo.ssoLoginInvalidResponse': 'AWS SSO 登录成功,但未获取到 access token。', + 'datasource.form.dynamo.ssoAccount': 'AWS 账号', + 'datasource.form.dynamo.ssoSelectAccount': '请选择 AWS 账号', + 'datasource.form.dynamo.ssoAccountOption': '{name}({id})', + 'datasource.form.dynamo.ssoRole': 'AWS 角色', + 'datasource.form.dynamo.ssoSelectRole': '请选择 AWS 角色', + 'datasource.form.dynamo.ssoAuthorize': '授权角色', + 'datasource.form.dynamo.ssoAuthorizeLoading': '授权角色中...', + 'datasource.form.dynamo.ssoAuthorizeSuccess': '角色凭证已加载。', + 'datasource.form.dynamo.ssoAuthorizeInvalidResponse': '角色授权成功,但未获取到凭证。', + 'datasource.form.quickInstallDocker': '快速安装(Docker)', + 'datasource.form.install.run': '运行', + 'datasource.form.install.stopRemove': '停止并删除', + 'datasource.form.install.connect': '连接', + 'datasource.form.testConnection': '测试连接', + 'datasource.form.title.edit': '编辑数据源', + 'datasource.form.title.create': '新建数据源', + 'datasource.form.databasePlaceholder': 'database', + 'datasource.form.hint.mysql': '必填:名称、主机、端口(默认 3306)、用户名。可选:数据库(默认 mysql)。', + 'datasource.form.hint.mysqlUri': 'MySQL Direct URL 模式:填写完整连接 URL。可通过 SSL/TLS 开关启用加密,并可选上传证书。', + 'datasource.form.hint.postgresql': '必填:名称、主机、端口(默认 5432)、用户名。可选:数据库(默认 postgres)、SSL/TLS 开关与证书上传。', + 'datasource.form.hint.postgresqlUri': 'PostgreSQL Direct URL 模式:填写完整连接 URL。可通过 SSL/TLS 开关启用加密,并可选上传证书。若关闭 SSL/TLS 且服务端不支持 SSL,请在 Direct URL 中显式加上 `?sslmode=disable`。', + 'datasource.form.hint.redis': '必填:名称、主机、端口。ACL 场景下密码可选。', + 'datasource.form.hint.elasticsearch': 'Elasticsearch:通过 host/port(默认 9200)连接。控制台使用 REST 请求语法:`GET /_cat/indices?v` 或 `POST //_search` + JSON body。', + 'datasource.form.hint.dynamodb': 'DynamoDB:需填写 region。推荐流程:选择 Profile 后单击 OAuth 完成授权。endpoint/profile/静态凭证仍可选。', + 'datasource.form.hint.mongodb': 'MongoDB:可选 Mongo URI(含 db/tls 参数)或用户名/密码模式。副本集支持多 host、可选 replicaSet,以及 SSL/TLS 证书上传。', + 'datasource.form.hint.d1': 'Cloudflare D1:先完成 OAuth 登录,再从下拉中选择已有数据库或新建数据库。', + 'datasource.form.hint.chromadb': 'ChromaDB:通过 host/port(默认 8000)连接。tenant/database 默认使用 default_tenant/default_database。控制台使用只读 REST 请求语法,例如 POST /collections//query + JSON body。', + 'datasource.form.fileReaderUnsupported': '当前环境不支持文件读取。', + 'datasource.form.awsCredentialsImported': '已导入 AWS 凭证。', + 'datasource.form.awsCredentialsImportedWithProfile': '已导入 AWS 凭证({profile})。', + + 'history.title': '历史', + 'history.subtitle': '查看执行历史记录。', + 'history.searchPlaceholder': '搜索语句、数据源或表名', + 'history.clearFiltered': '删除筛选结果', + 'history.clearFilters': '清除筛选', + 'history.loading': '加载历史中...', + 'history.loadFailed': '加载历史失败:{message}', + 'history.empty': '暂无历史。', + 'history.clearFilteredTitle': '清除筛选历史', + 'history.clearFilteredDesc': '仅会删除当前筛选条件命中的记录。', + 'history.filter.datasource': '数据源', + 'history.filter.target': '目标', + 'history.filter.targets': '目标', + 'history.filter.database': '数据库', + 'history.clearedNotice': '已清除 {count} 条记录。', + 'history.tab.console': '控制台历史', + 'history.tab.agentAudit': 'Agent 审计', + 'history.agent.empty': '还没有 agent 操作记录。', + 'history.agent.protocol.skill': 'Skill', + 'history.agent.protocol.mcp': 'MCP', + 'history.agent.protocol.cli': 'CLI', + 'history.agent.status.success': '成功', + 'history.agent.status.error': '失败', + 'history.agent.status.approval_required': '需要审批', + 'history.agent.unknown': '未知 agent', + 'history.agent.rename': '改名', + 'history.agent.renamePlaceholder': 'Agent 名称', + 'history.agent.renameSaving': '保存中…', + 'history.agent.tool': '工具', + 'history.agent.target': '目标', + 'history.agent.summary': '摘要', + 'history.agent.statement': '执行语句', + 'history.agent.filterLabel': 'Agent', + 'history.agent.filterAll': '所有 agent', + 'history.agent.revokedBadge': '已吊销', + 'history.agent.rejectionReason': '拒绝原因', + 'history.agent.risk.title': '风控决策', + 'history.agent.risk.actionLabel': '动作', + 'history.agent.risk.action.allow': '放行', + 'history.agent.risk.action.warn': '警告', + 'history.agent.risk.action.require_approval': '需要审批', + 'history.agent.risk.action.block': '阻断', + 'history.agent.risk.levelLabel': '严重程度', + 'history.agent.risk.level.low': '低', + 'history.agent.risk.level.medium': '中', + 'history.agent.risk.level.high': '高', + 'history.agent.risk.ruleLabel': '命中规则', + 'history.agent.risk.reasonsLabel': '原因', + 'history.agent.risk.sourcePolicy': '系统内置策略', + 'history.agent.risk.viewRule': '查看规则', + + 'visualization.title': '可视化', + 'visualization.subtitle': '让你的数据动起来。', + 'visualization.clearHistory': '清空历史', + 'visualization.savedCount': '已保存 {count} 条', + 'visualization.emptyHistory': '暂无已保存的可视化。', + 'visualization.untitled': '未命名', + 'visualization.emptyActive': '当前未选择可视化。可从控制台结果保存,或在历史中选择。', + 'visualization.rows': '{count} 行', + 'visualization.query': '查询语句', + 'visualization.settings': '可视化设置', + 'visualization.chart': '图表', + 'visualization.dimension': '维度', + 'visualization.metric': '指标', + 'visualization.aggregation': '聚合', + 'visualization.unsupportedRenderer': '不支持的渲染器:{renderer}', + + 'ai.panel.title': 'AI 设置', + 'ai.panel.subtitle': '自定义AI配置', + 'ai.panel.providers': 'AI配置列表', + 'ai.panel.addProvider': '新增配置', + 'ai.panel.empty': '尚未配置AI,点击“新增配置”开始。', + 'ai.panel.noConnected': '暂无已连接的AI配置。', + 'ai.panel.modelNotSet': '未设置模型', + 'ai.panel.moreActions': '更多操作', + 'ai.panel.needsAttention': '需要关注', + 'ai.panel.noFailed': '暂无失败的AI配置。', + 'ai.panel.deleteTitle': '删除 AI 配置', + 'ai.panel.deleted': 'AI 配置已删除', + 'ai.panel.tabChat': '对话模型', + 'ai.panel.tabEmbedding': '嵌入模型', + 'ai.panel.embeddingSubtitle': '配置向量搜索所需的嵌入模型。', + 'ai.panel.embeddingEmpty': '尚未配置嵌入模型。添加后可启用基于文本的向量搜索。', + 'ai.panel.embeddingDeleteTitle': '删除嵌入模型配置', + 'ai.panel.embeddingDeleted': '嵌入模型配置已删除', + 'ai.panel.embeddingNote': '嵌入模型将文本转换为向量,用于向量数据库的相似性搜索。', + 'ai.form.embeddingEndpointUrl': 'Embedding 接口地址', + 'ai.form.embeddingEndpointUrlPlaceholder': 'http://localhost:8901/v1/embeddings', + + 'ai.form.subtitle': '设置凭证、接口地址与模型参数。', + 'ai.form.configurationName': '配置名称', + 'ai.form.namePlaceholder': '生产 OpenAI', + 'ai.form.provider': '提供商', + 'ai.form.model': '模型', + 'ai.form.customModelPlaceholder': '自定义模型名', + 'ai.form.maxTokens': '最大 Tokens', + 'ai.form.maxTokensPlaceholder': '2048(默认)', + 'ai.form.apiBaseUrl': 'API Base URL', + 'ai.form.baseUrlPlaceholder': 'https://api.openai.com/v1', + 'ai.form.apiKey': 'API Key', + 'ai.form.apiKeyPlaceholder': 'sk-...', + 'ai.form.hideApiKey': '隐藏 API Key', + 'ai.form.showApiKey': '显示 API Key', + 'ai.form.customProviderNote': '自定义提供商使用 OpenAI 兼容的', + 'ai.form.testConnection': '测试连接', + 'ai.form.titleEdit': '编辑 AI 配置', + 'ai.form.titleCreate': '新建 AI 配置', + + 'ai.preferences.title': 'AI 聊天偏好', + 'ai.preferences.subtitle': '控制默认打开行为与会话保留策略。', + 'ai.preferences.defaultOpen': '默认打开', + 'ai.preferences.retention': '会话保留', + + 'ai.quickPrompt.removeContext': '移除上下文', + 'ai.quickPrompt.placeholder': '向 AI 提问...', + 'ai.quickPrompt.send': '发送', + + 'ai.sidebar.title': 'AI 聊天', + 'ai.sidebar.newChat': '新建聊天', + 'ai.sidebar.deleteConversation': '删除会话', + 'ai.sidebar.approvalRequired': '需要审批', + 'ai.sidebar.statementContext': '语句上下文', + 'ai.sidebar.label.datasource': '数据源', + 'ai.sidebar.label.database': '数据库', + 'ai.sidebar.label.risk': '风险', + 'ai.sidebar.label.trustLevel': '信任模式', + 'ai.sidebar.label.explain': 'EXPLAIN', + 'ai.sidebar.gateReason': '当前信任模式为"{trust}",{risk} 风险语句仍需你手动确认。可在"风险规则 → 信任模式"中调整。', + 'ai.sidebar.riskUnknown': '未知', + 'ai.sidebar.label.statement': '语句', + 'ai.sidebar.label.type': '类型', + 'ai.sidebar.label.rows': '行数', + 'ai.sidebar.label.size': '大小', + 'ai.sidebar.label.captured': '采集时间', + 'ai.sidebar.label.truncated': '是否截断', + 'ai.sidebar.label.name': '名称', + 'ai.sidebar.label.id': 'ID', + 'ai.sidebar.label.host': '主机', + 'ai.sidebar.label.port': '端口', + 'ai.sidebar.label.username': '用户名', + 'ai.sidebar.agent.chatmodel': '聊天模型', + 'ai.sidebar.agent.deepagent': '深度代理', + 'ai.sidebar.agent.planExecutor': '计划执行器', + 'ai.sidebar.agent.unknown': '代理', + 'ai.sidebar.plan.title': '计划', + 'ai.sidebar.plan.tabGroup': '计划视图', + 'ai.sidebar.plan.tab.markdown': 'Markdown', + 'ai.sidebar.plan.tab.workflow': '工作流', + 'ai.sidebar.plan.stepDefault': '步骤 {index}', + 'ai.sidebar.plan.empty': '暂无计划步骤。', + 'ai.sidebar.plan.status.pending': '待处理', + 'ai.sidebar.plan.status.inProgress': '进行中', + 'ai.sidebar.plan.status.completed': '已完成', + 'ai.sidebar.plan.status.blocked': '阻塞', + 'ai.sidebar.explainUsesIndex': '使用索引', + 'ai.sidebar.explainNoIndex': '未检测到索引', + 'ai.sidebar.bytes': '{count} 字节', + 'ai.sidebar.yes': '是', + 'ai.sidebar.no': '否', + 'ai.sidebar.analyzeRiskNote': + '该操作会将结果样本发送给已配置的 AI 提供商进行数据分析。仅在你确认可共享这些数据时批准。', + 'ai.sidebar.visualizationRiskNote': + '该操作会将结果样本发送给已配置的 AI 提供商生成数据可视化。仅在你确认可共享这些数据时批准。', + 'ai.sidebar.reject': '拒绝', + 'ai.sidebar.approve': '批准', + 'ai.sidebar.executing': '执行中…', + 'ai.sidebar.executingAction': '执行中…', + 'ai.sidebar.removeContext': '移除上下文', + 'ai.sidebar.placeholder': '向 AI 提问...', + 'ai.sidebar.voiceInput': '语音输入', + 'ai.sidebar.voiceInputSoon': '语音输入(即将支持)', + 'ai.sidebar.pause': '暂停', + 'ai.sidebar.send': '发送', + 'ai.sidebar.noProvider': '无可用的AI配置。', + 'ai.sidebar.providerFallback': 'AI配置', + 'ai.sidebar.pendingApprovalFirst': '请先批准或拒绝当前待处理请求。', + 'ai.sidebar.requestFailed': 'AI 请求失败。', + 'ai.sidebar.approvalFailed': '审批失败。', + 'ai.contextGroup.current': '当前', + 'ai.contextGroup.otherInDatasource': '同数据源其他项', + 'ai.contextGroup.otherDatasources': '其他数据源', + + 'console.title': '控制台', + 'console.view.resizeEntitiesEditor': '调整实体与编辑器面板大小', + 'console.view.resizeEditorResults': '调整编辑器与结果面板大小', + 'console.historyMini.title': '历史', + 'console.historyMini.more': '更多', + 'console.historyMini.empty': '暂无历史。', + 'console.resultsPanel.title': '结果', + 'console.resultsPanel.expandedView': '展开视图', + 'console.resultsPanel.close': '关闭', + 'console.switchDatasource': '切换数据源', + 'console.refreshEntities': '刷新实体', + 'console.label.key': '键', + 'console.label.collection': '集合', + 'console.label.index': '索引', + 'console.label.table': '表', + 'console.label.keyUpper': 'KEY', + 'console.label.collectionUpper': 'COLLECTION', + 'console.label.indexUpper': 'INDEX', + 'console.label.tableUpper': 'TABLE', + 'console.statementTitle.redis': 'Redis 命令', + 'console.statementTitle.elastic': 'Elasticsearch 请求', + 'console.statementTitle.dynamo': 'DynamoDB PartiQL', + 'console.statementTitle.chroma': 'ChromaDB 请求', + 'console.statementTitle.default': '语句', + 'console.dynamo.controls.title': 'DynamoDB 执行限制', + 'console.dynamo.controls.triggerLabel': '执行限制', + 'console.dynamo.controls.triggerAriaLabel': 'DynamoDB 执行限制:{summary}', + 'console.dynamo.controls.summary': '{pageSize} · 返回 {maxReturnedRows} · {maxPages} 页', + 'console.dynamo.controls.range': '{min}–{max}', + 'console.dynamo.controls.reset': '恢复默认', + 'console.dynamo.controls.close': '关闭', + 'console.dynamo.controls.section.perRequest': '单次 PartiQL 请求', + 'console.dynamo.controls.section.budget': '本次执行的总预算', + 'console.dynamo.controls.footer': '任一上限触发,立即停止并返回当前结果。', + 'console.dynamo.controls.pageSize.label': '单页', + 'console.dynamo.controls.pageSize.fullLabel': 'pageSize', + 'console.dynamo.controls.pageSize.help': '每次向 DynamoDB 发起 ExecuteStatement 时的 Limit。', + 'console.dynamo.controls.maxReturnedRows.label': '返回', + 'console.dynamo.controls.maxReturnedRows.fullLabel': '期望获取的数据行数', + 'console.dynamo.controls.maxReturnedRows.help': '未达到期望行数则一直自动翻页查询,直到达到最大翻页次数;若先达到则返回已获取的数据。', + 'console.dynamo.controls.maxPages.label': '页数', + 'console.dynamo.controls.maxPages.fullLabel': '最大翻页次数', + 'console.dynamo.controls.maxPages.range': '≥ 1', + 'console.dynamo.controls.maxPages.help': '未达到期望行数时执行的最多翻页次数。实际值默认会受风控策略上限约束。', + 'console.dynamo.status.effective': '限制:单页 {pageSize},返回 {maxReturnedRows},页数 {maxPages}', + 'console.dynamo.status.pageSize': '单页 {pageSize}', + 'console.dynamo.status.maxPages': '页数 {maxPages}', + 'console.dynamo.status.maxEvaluatedItems': '评估 {maxEvaluatedItems}', + 'console.dynamo.status.pagesFetched': '已读取 {pages} 页', + 'console.dynamo.status.clampedLimits': '已收紧:{limits}', + 'console.dynamo.status.limitName.pageSize': '单页数量', + 'console.dynamo.status.limitName.maxReturnedRows': '返回行数上限', + 'console.dynamo.status.limitName.maxPages': '翻页次数上限', + 'console.dynamo.status.limitName.maxEvaluatedItems': '评估项上限', + 'console.dynamo.status.stopReason.returned_row_limit': '已按返回行数限制停止。', + 'console.dynamo.status.stopReason.page_limit': '已按页数限制停止。', + 'console.dynamo.status.stopReason.evaluated_item_limit': '已按评估 item 限制停止。', + 'console.dynamo.status.stopReason.no_more_pages': '已读取全部匹配页面。', + 'console.dynamo.status.stopReason.empty_page_has_more': '遇到空页且仍有后续数据,已停止。', + 'console.dynamo.status.stopReason.fallback': '停止原因:{stopReason}', + 'console.dynamo.repair.title': '语句自动修复', + 'console.dynamo.repair.reason': '已修正 PartiQL 引用写法,可直接执行。', + 'console.dynamo.suggestion.title': '索引建议', + 'console.dynamo.suggestion.reason': '可使用匹配的二级索引以减少扫描数据。', + 'console.dynamo.hint.preview': '建议语句', + 'console.dynamo.action.applyAndRun': '应用并执行', + 'console.dynamo.action.replaceOnly': '仅替换', + 'console.dynamo.action.execute': '执行', + 'console.dynamo.action.replace': '替换', + 'console.entityTitle.datasources': '数据源', + 'console.entityTitle.keys': '键', + 'console.entityTitle.indices': '索引', + 'console.entityTitle.tables': '表', + 'console.entityTitle.collections': 'Collections', + 'console.entityTitle.entities': '实体', + 'console.entityKind.generic': '通用', + 'console.entityKind.mongo': 'Mongo', + 'console.entityKind.redis': 'Redis', + 'console.entityKind.es': 'ES', + 'console.entityKind.ddb': 'DDB', + 'console.entityKind.chroma': 'Chroma', + 'console.entityKind.sql': 'SQL', + 'console.filter.pattern': '模式', + 'console.filter.filter': '筛选', + 'console.filter.placeholder.redis': 'user:*', + 'console.filter.placeholder.default': 'orders', + 'console.filter.hint.redis': '支持 Redis glob 模式;留空表示全部 key。', + 'console.filter.hint.server': '大列表场景下筛选会请求服务端。', + 'console.filter.hint.local': '筛选仅在本地生效。', + 'console.empty.databases': '未找到数据库。', + 'console.empty.keys': '未找到键。', + 'console.empty.entities': '未找到实体。', + 'console.subtitle.selectDatasource': '请选择一个数据源开始。', + 'console.subtitle.dbNotSet': 'db: 未设置', + 'console.subtitle.tenant': 'tenant: {value}', + 'console.subtitle.region': 'region: {value}', + 'console.subtitle.endpoint': 'endpoint: {value}', + 'console.entities.mappings': '映射', + 'console.entities.fields': '字段', + 'console.entities.stats': '统计', + 'console.entities.indexes': '索引', + 'console.entities.chroma.id': '集合 ID', + 'console.entities.chroma.dimension': '向量维度', + 'console.entities.chroma.records': '记录数', + 'console.entities.chroma.metadata': '元数据', + 'console.entities.refresh': '刷新', + 'console.entities.databases': '数据库', + 'console.entities.createDatabase': '新建数据库', + 'console.entities.loadingKeys': '键加载中...', + 'console.entities.collapse': '收起', + 'console.entities.expand': '展开', + 'console.entities.collapseDetails': '收起详情', + 'console.entities.expandDetails': '展开详情', + 'console.entities.loadingDetails': '详情加载中...', + 'console.entities.failed': '失败:{message}', + 'console.entities.details': '实体详情', + 'console.entities.field': '字段', + 'console.entities.column': '列', + 'console.entities.nullable': '可空', + 'console.entities.default': '默认值', + 'console.entities.noMappings': '暂无映射。', + 'console.entities.noFields': '暂无字段。', + 'console.entities.noStats': '暂无统计。', + 'console.entities.noIndexes': '暂无索引。', + 'console.entities.tableKeys': '主键', + 'console.entities.secondaryIndexes': '二级索引', + 'console.entities.indexColumns': '列', + 'console.entities.noDetails': '暂无可用详情。', + 'console.entities.loadingMore': '加载更多中...', + 'console.entities.createTableTitle': '建表语句', + 'console.entities.createTableCopied': '已复制 CREATE TABLE 语句。', + 'console.entities.noCreateTableOutput': '未找到 CREATE TABLE 输出。', + 'console.entities.loading': '加载中...', + 'console.entities.close': '关闭', + 'console.entities.view': '视图', + 'console.statement.noTargetUpper': '未选择目标', + 'console.statement.noTargetSelected': '未选择目标', + 'console.statement.tabs': '语句标签', + 'console.statement.newTab': '新建语句标签', + 'console.statement.closeTab': '关闭标签', + 'console.statement.renameTab': '重命名标签', + 'console.statement.scrollTabsLeft': '向左滚动标签', + 'console.statement.scrollTabsRight': '向右滚动标签', + 'console.statement.tabTitleWithDatasource': '{datasource} · {title}', + 'console.statement.executeStatement': '执行语句', + 'console.statement.typeToExecute': '请输入语句后执行', + 'console.statement.execute': '执行', + 'console.statement.explainPlan': '查看执行计划', + 'console.statement.typeToExplain': '请输入语句后查看执行计划', + 'console.statement.explain': '解释', + 'console.statement.beautify': '格式化', + 'console.statement.cursor': '第 {line} 行,第 {column} 列', + 'console.statement.currentTarget': '当前目标', + 'console.statement.placeholder': 'SQL、Mongo JSON、Redis 命令、Elasticsearch 请求、DynamoDB PartiQL 或 ChromaDB 请求', + 'console.statement.runnableStatements': '可执行语句', + 'console.statement.executeAll': '执行全部', + 'console.statement.explainAll': '解释全部', + 'console.statement.executeStatementWithIndex': '执行语句 {index}', + 'console.statement.analyzePostgres': 'Analyze', + 'console.elastic.dsl.queryBuilder': '查询构建器', + 'console.elastic.dsl.subtitle': '可通过筛选器精确检索,或直接编辑 DSL。', + 'console.elastic.dsl.addFilter': '新增筛选', + 'console.elastic.dsl.liveEditor': '实时 DSL 编辑器', + 'console.elastic.dsl.reset': '重置', + 'console.elastic.dsl.runSearch': '运行搜索', + 'console.elastic.dsl.dslTitle': 'Elasticsearch DSL', + 'console.elastic.dsl.syncActive': '同步开启', + 'console.elastic.dsl.validJson': 'JSON 有效', + 'console.elastic.dsl.invalidJson': 'JSON 无效', + 'console.elastic.dsl.prettifyJson': '美化 JSON', + 'console.elastic.dsl.copyDsl': '复制 DSL', + 'console.elastic.dsl.filterFieldPlaceholder': '字段', + 'console.elastic.dsl.filterFieldSearchPlaceholder': '搜索字段...', + 'console.elastic.dsl.filterValuePlaceholder': '值', + 'console.elastic.dsl.filterValueTokenPlaceholder': '输入值后按回车', + 'console.elastic.dsl.filterOperatorAria': '筛选操作符', + 'console.elastic.dsl.applyFilter': '应用', + 'console.elastic.dsl.updateFilter': '更新', + 'console.elastic.dsl.noMatchingFields': '没有匹配字段', + 'console.elastic.dsl.unsupportedClausesTitle': '查询构建器存在不支持的结构', + 'console.elastic.dsl.unsupportedClausesBody': '查询构建器只展示可识别条件;复杂子句请在 DSL 编辑器中修改。', + 'console.elastic.dsl.operatorNotEqual': '不等于', + 'console.elastic.dsl.operatorIn': '在列表中', + 'console.elastic.dsl.operatorNotIn': '不在列表中', + 'console.elastic.dsl.operatorContains': '包含', + 'console.elastic.dsl.operatorNotContains': '不包含', + 'console.elastic.dsl.operatorMatchPhrase': '短语匹配', + 'console.elastic.dsl.operatorPrefix': '前缀匹配', + 'console.elastic.dsl.operatorWildcard': '通配符匹配', + 'console.elastic.dsl.operatorRegexp': '正则匹配', + 'console.elastic.dsl.operatorExists': '字段存在', + 'console.elastic.dsl.operatorNotExists': '字段不存在', + 'console.elastic.dsl.dslCopied': 'DSL 已复制。', + 'console.elastic.dsl.dslCopyFailed': '复制 DSL 失败。', + 'console.chroma.dsl.queryBuilder': '查询构建器', + 'console.chroma.dsl.subtitle': '通过筛选条件或直接编辑 DSL 来查询。', + 'console.chroma.dsl.modeLabel': 'Chroma 请求模式', + 'console.chroma.dsl.modeGet': '读取集合', + 'console.chroma.dsl.modeQuery': '相似度搜索', + 'console.chroma.dsl.currentCollection': '当前集合', + 'console.chroma.dsl.limit': '限制数量', + 'console.chroma.dsl.topK': '返回数量', + 'console.chroma.dsl.include': '返回内容', + 'console.chroma.dsl.ids': 'IDs', + 'console.chroma.dsl.idsPlaceholder': '[\"doc-1\", \"doc-2\"]', + 'console.chroma.dsl.queryTexts': '查询文本', + 'console.chroma.dsl.queryTextsPlaceholder': '[\"如何重置密码\"]', + 'console.chroma.dsl.primaryInput': '主要搜索输入', + 'console.chroma.dsl.primaryQuery': '搜索内容', + 'console.chroma.dsl.queryInputPlaceholder': '输入搜索文本...', + 'console.chroma.dsl.queryInputHelper': '默认直接输入普通文本即可。只有真的需要多条查询时再换行。', + 'console.chroma.dsl.idList': '文档 ID', + 'console.chroma.dsl.idListPlaceholder': '按 ID 过滤(可选):id-1, id-2, ...', + 'console.chroma.dsl.idListHelper': '每行一个 id,或用逗号分隔。', + 'console.chroma.dsl.quickOptions': '结果选项', + 'console.chroma.dsl.requestPreview': '请求预览', + 'console.chroma.dsl.showAdvanced': '显示高级筛选', + 'console.chroma.dsl.hideAdvanced': '收起高级筛选', + 'console.chroma.dsl.showRawRequest': '显示原始请求', + 'console.chroma.dsl.hideRawRequest': '收起原始请求', + 'console.chroma.dsl.modeGetDescription': '当你已经知道文档 id 时,用这个模式直接读取少量结果。', + 'console.chroma.dsl.modeQueryDescription': '直接输入文本做向量相似度搜索,并决定是否显示 distance。', + 'console.chroma.dsl.queryCount': '{count} 条查询', + 'console.chroma.dsl.idCount': '{count} 个 id', + 'console.chroma.dsl.includeOption.documents': '显示文档', + 'console.chroma.dsl.includeOption.metadatas': '显示元数据', + 'console.chroma.dsl.includeOption.distances': '显示 distance', + 'console.chroma.dsl.includeOption.embeddings': '显示向量', + 'console.chroma.dsl.where': '元数据筛选', + 'console.chroma.dsl.wherePlaceholder': '{"field": "value"}, {"field": {"$gte": 10}}', + 'console.chroma.dsl.whereHint': '$eq $ne $gt $gte $lt $lte $in $nin · $and $or', + 'console.chroma.dsl.whereDocument': '文档内容筛选', + 'console.chroma.dsl.whereDocumentPlaceholder': '{"$contains": "关键词"}', + 'console.chroma.dsl.whereDocumentHint': '$contains $not_contains · $and $or', + 'console.chroma.dsl.invalidJsonHint': 'JSON 格式错误 — 键和字符串值需要使用双引号', + 'console.chroma.dsl.requestBody': '请求体', + 'console.chroma.dsl.validJson': 'JSON 有效', + 'console.chroma.dsl.invalidJson': 'JSON 无效', + 'console.chroma.dsl.queryInputRequired': '请至少添加一个查询输入', + 'console.chroma.dsl.queryInputHint': '相似度搜索至少需要一条查询文本,或者在请求体里补充其他查询输入。', + 'console.chroma.dsl.reset': '重置', + 'console.chroma.dsl.runSearch': '运行搜索', + 'console.chroma.dsl.copyRequest': '复制请求', + 'console.chroma.dsl.bodyCopied': '请求已复制。', + 'console.chroma.dsl.bodyCopyFailed': '复制请求失败。', + 'console.chroma.dsl.vectorSearch': '向量', + 'console.chroma.dsl.textSearch': '文本', + 'console.chroma.dsl.vectorSearchHint': 'ChromaDB REST API 需要预计算的 embedding 向量进行相似度搜索', + 'console.chroma.dsl.textSearchHint': '使用嵌入模型将文本转换为向量,再进行相似度搜索', + 'console.chroma.dsl.embeddingsPlaceholder': '[0.1, 0.2, 0.3, ...]', + 'console.chroma.dsl.textSearchPlaceholder': '输入搜索文本...', + 'console.chroma.dsl.selectEmbeddingModel': '选择嵌入模型', + 'console.chroma.dsl.noEmbeddingModels': '未配置嵌入模型', + 'console.chroma.dsl.configureInSettings': '前往 AI 设置配置', + 'console.chroma.dsl.computing': '计算中...', + 'console.chroma.dsl.maxDistance': '最大距离', + 'console.chroma.dsl.maxDistancePlaceholder': '∞', + 'console.chroma.dsl.liveEditor': 'DSL 实时编辑器', + 'console.chroma.dsl.filters': '过滤条件', + 'console.chroma.dsl.syncActive': '同步中', + 'console.chroma.dsl.prettifyJson': '格式化 JSON', + 'console.editor.formatStatement': '格式化语句', + 'console.d1.executionMode': 'D1 执行模式', + 'console.d1.executionMode.dev': 'dev', + 'console.d1.executionMode.remote': 'remote', + 'console.d1.deploy': '部署', + 'console.d1.deploying': '部署中...', + 'console.d1.deploySuccess': 'D1 migration 已部署到 remote。', + 'console.results.tabs': '结果标签', + 'console.results.resultOne': '结果 1', + 'console.results.resultWithIndex': '结果 {index}', + 'console.results.filterField': '筛选字段', + 'console.results.allFields': '全部字段', + 'console.results.filterButton': '筛选', + 'console.results.searchButton': '搜索', + 'console.results.clearAllFilters': '清空全部', + 'console.results.filterAddPlaceholder': '新增筛选', + 'console.results.filterPanelTitle': '筛选条件', + 'console.results.filterEditTitle': '编辑筛选', + 'console.results.filterSearchColumns': '搜索字段...', + 'console.results.filterChangeField': '更换字段', + 'console.results.filterOperatorAria': '筛选操作符', + 'console.results.filterOperatorLabel': '操作符', + 'console.results.filterValueLabel': '值', + 'console.results.filterValuePlaceholder': '值', + 'console.results.filterApply': '应用筛选', + 'console.results.filterUpdate': '更新筛选', + 'console.results.filterNoFields': '当前目标没有可用筛选字段。', + 'console.results.filterNeedsTarget': '请先选择要筛选的表或集合。', + 'console.results.filterUnsupported': '当前数据源暂不支持按字段生成筛选查询。', + 'console.results.filterOperatorEq': '=', + 'console.results.filterOperatorContains': '包含', + 'console.results.filterOperatorGt': '>', + 'console.results.filterOperatorGte': '>=', + 'console.results.filterOperatorLt': '<', + 'console.results.filterOperatorLte': '<=', + 'console.results.filterOperatorIsNull': '为空', + 'console.results.filterOperatorIsNotNull': '不为空', + 'console.results.filterValueNull': 'NULL', + 'console.results.filterValueNotNull': 'NOT NULL', + 'console.results.export': '导出', + 'console.results.exported': '已导出到 {path}。', + 'console.results.exportFailed': '导出失败。', + 'console.results.visualization': '可视化', + 'console.results.expand': '展开', + 'console.results.prev': '上一页', + 'console.results.next': '下一页', + 'console.results.pageLabel': '第 {page} 页', + 'console.results.copyPage': '复制本页', + 'console.results.copyJson': '复制 JSON', + 'console.results.itemLabel': '条目', + 'console.results.loadMore': '加载更多', + 'console.results.loading': '加载中...', + 'console.results.pageSize': '每页数量', + 'console.results.noOutput': '无输出。', + 'console.results.noResultsYet': '暂无结果。', + 'console.results.shortcutPrefix': '使用', + 'console.results.shortcutSuffix': '可快速执行。', + 'console.results.prevPageAria': '上一页', + 'console.results.currentPageAria': '当前页', + 'console.results.nextPageAria': '下一页', + 'console.results.clickExecute': '点击执行以运行示例查询', + 'console.results.selectTargetExecute': '先选择目标再执行', + 'console.results.selectTargetExecuteWithPeriod': '先选择目标再执行。', + 'console.results.ready': '就绪', + 'console.results.rowsFiltered': '行数:{filtered} / {total}', + 'console.results.rowsTotal': '行数:{total}', + 'console.results.rowsTotalWithElapsed': '行数:{total} | {ms}ms', + 'console.results.filterPlaceholderAll': '筛选结果...', + 'console.results.filterPlaceholderField': '筛选 {field}...', + 'console.results.filterNullToken': '[NULL]', + 'console.results.filterNullOption': '空值(NULL)', + 'console.results.showingZeroOfZero': '显示 0 / 0', + 'console.results.showingZeroOfTotal': '显示 0 / {total}', + 'console.results.showingRangeOfTotal': '显示 1-{visible} / {total}', + 'console.results.shortcutTip': '使用 Ctrl/Cmd + Enter 可快速执行。', + 'console.results.rowCopied': '行已复制。', + 'console.results.pageCopied': '页面已复制。', + 'console.results.mongoResultsCopied': 'Mongo 结果已复制。', + 'console.results.redisResultCopied': 'Redis 结果已复制。', + 'console.results.resultsCopied': '结果已复制。', + 'console.results.rowDeleteTitle': '确认删除此行?', + 'console.results.rowUpdateTitle': '确认更新此行?', + 'console.results.rowMutationSubtitle': '执行前请确认以下语句。', + 'console.results.rowMutationTableLabel': '表', + 'console.results.rowMutationPkLabel': '主键', + 'console.results.rowMutationColumnLabel': '列', + 'console.results.rowMutationCurrentLabel': '当前值', + 'console.results.rowMutationNewValueLabel': '新值', + 'console.results.rowMutationPreviewLabel': '将执行', + 'console.results.rowMutationSetNull': '置为 NULL', + 'console.results.rowMutationConfirmDelete': '删除此行', + 'console.results.rowMutationConfirmUpdate': '确认更新', + 'console.results.rowMutationCancel': '取消', + 'console.results.rowDeleteAction': '删除此行', + 'console.results.rowEditAction': '双击编辑', + 'console.results.rowDeletedSuccess': '已删除 1 行。', + 'console.results.rowUpdatedSuccess': '已更新 1 行。', + 'console.results.rowMutationFailed': '行操作失败:{error}', + 'console.results.rowMutationRiskBlocked': '行操作被风险规则拦截({reasons})。请从编辑器运行该语句以查看并批准。', + 'console.results.rowMutationNoRowsAffected': '行操作已执行,但未影响任何行。服务器数据可能已变动,请刷新结果以确认。', + 'console.results.rowMissingPkValue': '当前行的主键列 {columns} 缺少值,无法执行操作。', + 'console.results.rowMutationPkNotEditable': '主键列 {column} 不支持行内编辑。', + 'console.results.rowMutationColumnNotFound': '列 {column} 不在目标表结构中。', + 'console.results.rowMutationUnavailable': '仅当结果集为含主键的单表 SELECT 时才能进行行级快捷操作。', + 'console.elastic.results.documentResults': '文档结果', + 'console.elastic.results.showingRange': '显示 {from}-{to} / {total} 条命中', + 'console.elastic.results.rawJsonView': '原始 JSON', + 'console.elastic.results.noFields': '暂无字段。', + 'console.elastic.results.noHits': '没有匹配的命中结果。', + 'console.elastic.results.viewMetadata': '查看元数据', + 'console.elastic.results.hideMetadata': '隐藏元数据', + 'console.elastic.results.metadataTitle': '元数据', + 'console.elastic.results.collapseSidebar': '收起', + 'console.elastic.results.expandSidebar': '展开', + 'console.elastic.results.fieldsCollapsed': '可用字段', + 'console.elastic.results.searchHitsPlaceholder': '搜索当前可见命中...', + 'console.elastic.results.visibleCopied': '可见结果已复制。', + 'console.elastic.results.title': '文档结果', + 'console.elastic.results.hitsMeta': '{total} 条命中,用时 {ms}ms', + 'console.elastic.results.hitsMetaNoTime': '{total} 条命中', + 'console.elastic.results.resultWindowHint': '偏移分页仅支持前 {limit} 条命中,请收窄查询条件后再查看更深结果。', + 'console.elastic.results.deepPagingDatasourceRequired': '翻页前请先选择数据源。', + 'console.elastic.results.deepPagingUnavailable': '当前请求不支持深分页。', + 'console.elastic.results.deepPagingBuildRequestFailed': '准备深分页请求失败。', + 'console.elastic.results.deepPagingPageUnreachable': '使用深分页无法到达请求的页码。', + 'console.elastic.results.deepPagingBuildFinalRequestFailed': '准备目标页请求失败。', + 'console.elastic.results.summary': '显示 {visible} / {total} 条文档', + 'console.elastic.results.availableFields': '可用字段', + 'console.elastic.results.filterFieldsPlaceholder': '筛选字段...', + 'console.elastic.results.listView': '表格', + 'console.elastic.results.rawJson': '原始 JSON', + 'console.elastic.results.expandAll': '展开全部', + 'console.elastic.results.collapseAll': '收起全部', + 'console.elastic.results.copyRawValue': '复制原值', + 'console.elastic.results.rawValueCopied': '原值已复制。', + 'console.elastic.results.documentSource': '文档内容', + 'console.elastic.results.columnTimestamp': '@时间戳', + 'console.elastic.results.columnId': '_id', + 'console.elastic.results.columnStatus': '状态', + 'console.elastic.results.columnMessage': '消息', + 'console.elastic.results.unknownType': '未知', + 'console.elastic.results.unknownStatus': '未知', + 'console.elastic.results.noMessage': '-', + 'console.chroma.results.title': '文档结果', + 'console.chroma.results.hitsMeta': '{total} 条文档,用时 {ms}ms', + 'console.chroma.results.hitsMetaNoTime': '{total} 条文档', + 'console.chroma.results.showingRange': '显示 {from}-{to} / {total} 条文档', + 'console.chroma.results.listView': '表格', + 'console.chroma.results.rawJson': '原始 JSON', + 'console.chroma.results.expandAll': '展开全部', + 'console.chroma.results.collapseAll': '收起全部', + 'console.chroma.results.copyRawValue': '复制原值', + 'console.chroma.results.recordDetail': '记录详情', + 'console.chroma.results.metaId': 'ID', + 'console.chroma.results.metaDistance': '距离', + 'console.chroma.results.documentSource': '文档内容', + 'console.chroma.results.viewMetadata': '查看元数据', + 'console.chroma.results.hideMetadata': '隐藏元数据', + 'console.lifecycle.noDatasources': '暂无可用数据源。', + 'console.lifecycle.noConnectedDatasources': '暂无已连接数据源。', + 'console.history.entryDatasourceMismatch': '历史记录与当前数据源不匹配。', + 'console.beautify.mongoUseDbMethod': '格式化前请使用 db..(...) 语法。', + 'console.beautify.mongoAddArguments': '格式化前请先补充参数。', + 'console.beautify.mongoInvalidStatement': 'Mongo 语句无效,请修复语法后再格式化。', + 'console.beautify.sqlFailed': 'SQL 格式化失败。', + 'console.mongo.promptDatabaseName': '数据库名称', + 'console.suggestions.mongoHelpers': 'Mongo 辅助', + 'console.suggestions.sqlHelpers': 'SQL 辅助', + 'console.suggestions.dynamoHelpers': 'DynamoDB 辅助', + 'console.redisDanger.title': '确认执行 Redis 命令', + 'console.redisDanger.subtitle': '该命令可能阻塞 Redis 或影响可用性。', + 'console.redisDanger.detected': '检测结果:{value}', + 'console.redisDanger.highRisk': '高风险', + + 'redis.inspector.title': '键检查器', + 'redis.inspector.eyebrow.selected': '已选中', + 'redis.inspector.eyebrow.none': '未选择键', + 'redis.inspector.meta.keyDetailsPreview': 'Redis 键详情与预览。', + 'redis.inspector.meta.commandOutput': 'Redis 命令输出。', + 'redis.inspector.meta.selectKeyToInspect': '请选择一个键查看详情。', + 'redis.inspector.newKey': '新建键', + 'redis.inspector.copyKey': '复制键', + 'redis.inspector.key': '键', + 'redis.inspector.tabs': 'Redis 检查器标签', + 'redis.inspector.previewTab': '预览', + 'redis.inspector.outputTab': '输出', + 'redis.inspector.clearOutput': '清空输出', + 'redis.inspector.firstItems': '前 {limit} 个 {kind} 项。', + 'redis.inspector.viewFull': '查看完整内容', + 'redis.inspector.loadingFull': '正在加载完整值...', + 'redis.inspector.failedLoadFull': '加载完整值失败:{message}', + 'redis.inspector.noPreviewItems': '没有可预览内容。', + 'redis.inspector.selectKeyToPreview': '请选择要预览的键。', + 'redis.inspector.noPreviewAvailable': '暂无可预览内容。', + 'redis.inspector.resultTabs': '结果标签', + 'redis.inspector.clearTabs': '清空标签', + 'redis.inspector.commandOutput': '命令输出', + 'redis.inspector.noOutput': '无输出。', + 'redis.inspector.keyCopied': '键已复制。', + 'redis.inspector.showingAll': '已显示全部 {count} 项。', + 'redis.inspector.emptyString': '字符串为空。', + 'redis.inspector.emptyHash': '哈希为空 — 没有字段。', + 'redis.inspector.emptyList': '列表为空 — 没有元素。', + 'redis.inspector.emptySet': '集合为空 — 没有成员。', + 'redis.inspector.emptyZset': '有序集合为空 — 没有成员。', + 'redis.inspector.emptyStream': '流为空 — 没有条目。', + 'redis.inspector.typeLong.string': '字符串', + 'redis.inspector.typeLong.hash': '哈希', + 'redis.inspector.typeLong.list': '列表', + 'redis.inspector.typeLong.set': '集合', + 'redis.inspector.typeLong.zset': '有序集合', + 'redis.inspector.typeLong.stream': '流', + 'redis.inspector.chipJson': 'JSON', + 'redis.inspector.chipProtobuf': 'Protobuf', + 'redis.inspector.chipBinary': '二进制', + 'redis.inspector.chipBinaryHint': '值包含非文本字节,已切换为十六进制展示。', + 'redis.inspector.jsonToggleRaw': '原始', + 'redis.inspector.jsonTogglePretty': '美化', + 'redis.inspector.viewModeLabel': '查看模式', + 'redis.inspector.viewModeAuto': '自动', + 'redis.inspector.viewModeText': '文本', + 'redis.inspector.viewModeHex': '十六进制', + 'redis.inspector.hexTruncated': '十六进制视图仅展示前 64 KiB,实际值更长。', + 'redis.typeBadge.ariaLoading': '正在加载键类型', + 'redis.typeBadge.ariaUnknown': '未知键类型', + + 'redis.shell.keysPanel': '键面板', + 'redis.shell.defaultName': 'Redis', + 'redis.shell.refreshKeys': '刷新键列表', + 'redis.shell.searchKey': '搜索键', + 'redis.shell.searchKeys': '搜索键', + 'redis.shell.keys': '键', + 'redis.shell.scrollKeysLeft': '键列表左滚', + 'redis.shell.scrollKeysRight': '键列表右滚', + 'redis.shell.loadingKeys': '键加载中...', + 'redis.shell.noResults': '无结果', + 'redis.shell.keyInspectorAria': '键检查器', + 'redis.shell.resourceUsageAria': '资源使用', + 'redis.shell.node': '节点', + 'redis.shell.memory': '内存', + 'redis.shell.cpu': 'CPU', + 'redis.shell.ttl': 'TTL(生存时间)', + 'redis.shell.memoryUsage': '内存占用', + 'redis.shell.encoding': '编码', + 'redis.shell.metaTtl': 'TTL', + 'redis.shell.metaSize': '大小', + 'redis.shell.metaEnc': '编码', + 'redis.shell.tab.value': '值', + 'redis.shell.tab.json': 'JSON', + 'redis.shell.tab.raw': '原始', + 'redis.shell.tab.protobuf': 'Protobuf', + 'redis.shell.copyContent': '复制内容', + 'redis.shell.expandView': '展开视图', + 'redis.shell.schemaSource': 'Schema 来源', + 'redis.shell.editor': '编辑器', + 'redis.shell.upload': '上传', + 'redis.shell.schemaPlaceholder': 'syntax = "proto3";\n\nmessage MyMessage {\n string id = 1;\n}', + 'redis.shell.messageType': '消息类型', + 'redis.shell.noMessageTypeYet': '暂无消息类型。', + 'redis.shell.schemaDecoding': '使用 {source} 中的 {message} 消息解码当前值。', + 'redis.shell.consoleCli': '控制台 CLI', + 'redis.shell.consoleWindowControls': '控制台窗口控件', + 'redis.shell.enterCommandPlaceholder': '输入命令...', + 'redis.shell.enterCommandAria': '输入 Redis 命令', + 'redis.shell.commandSuggestions': '命令提示', + 'redis.shell.clipboardUnavailable': '当前环境不可用剪贴板。', + 'redis.shell.commandCopied': '命令已复制。', + 'redis.shell.fileReadFailed': '读取文件失败。', + 'redis.shell.fileReaderUnsupported': '当前环境不支持文件读取。', + 'redis.shell.loadedSchema': '已加载 {name}。', + 'redis.shell.schemaHintEditUpload': '编辑或上传 .proto schema 以解码当前键值。', + 'redis.shell.schemaNoMessageType': '当前 schema 未发现消息类型。', + 'redis.shell.notJsonValue': '该值不是 JSON。', + 'redis.protobuf.schema.label': 'Schema', + 'redis.protobuf.schema.placeholder': '选择 schema…', + 'redis.protobuf.schema.empty': '尚未保存 schema', + 'redis.protobuf.schema.search': '搜索 schema', + 'redis.protobuf.schema.uploadHint': '上传一个 .proto 文件开始使用。', + 'redis.protobuf.message.label': '消息类型', + 'redis.protobuf.message.placeholder': '选择消息…', + 'redis.protobuf.message.search': '搜索消息', + 'redis.protobuf.message.empty': '当前 schema 未发现消息类型。', + 'redis.protobuf.manage.open': '管理 schema', + 'redis.protobuf.manage.title': '管理 protobuf schema', + 'redis.protobuf.manage.upload': '上传 .proto 文件', + 'redis.protobuf.manage.add': '新建 schema', + 'redis.protobuf.manage.rename': '重命名', + 'redis.protobuf.manage.delete': '删除', + 'redis.protobuf.manage.confirmDelete': '删除 "{name}"?', + 'redis.protobuf.manage.empty': '尚未保存 schema。请上传一个 .proto 文件。', + 'redis.protobuf.manage.close': '关闭', + 'redis.protobuf.manage.save': '保存', + 'redis.protobuf.manage.cancel': '取消', + 'redis.protobuf.manage.namePlaceholder': 'Schema 名称(如 user.proto)', + 'redis.protobuf.manage.contentPlaceholder': 'syntax = "proto3";\n\nmessage MyMessage {\n string id = 1;\n}', + 'redis.protobuf.manage.failed': '保存 schema 失败:{error}', + 'redis.protobuf.manage.deleteFailed': '删除 schema 失败:{error}', + 'redis.protobuf.manage.loadFailed': '加载 schema 失败:{error}', + 'redis.protobuf.manage.requireNameContent': '名称和内容均为必填。', + 'redis.protobuf.manage.fileTooLarge': '文件过大(上限 {limit})。', + 'redis.protobuf.manage.fileReadFailed': '无法读取文件:{error}', + 'redis.protobuf.manage.closeIcon': '关闭对话框', + 'redis.protobuf.auto.high': '自动识别为 {message}(高置信)', + 'redis.protobuf.auto.medium': '自动识别为 {message}', + 'redis.protobuf.auto.low': '可能匹配:{message}', + 'redis.protobuf.auto.tooLarge': '数据过大,跳过自动识别(>64KB)。', + 'redis.protobuf.savedImported': '已将原有 schema 文本导入到管理列表。', + 'redis.protobuf.importedName': '已导入 schema', + 'redis.protobuf.manage.contentLabel': '.proto 内容', + 'redis.protobuf.hint.selectSchema': '选择一个 schema 以解码当前键。', + 'redis.protobuf.hint.selectMessage': '选择一个消息类型以解码当前键。', + + 'visualization.builder.hint': '选择图表参数后,可直接打开到可视化页面。', + 'visualization.builder.close': '关闭', + 'visualization.builder.noSimpleFields': '没有可用于可视化的简单字段。', + 'visualization.builder.chart': '图表', + 'visualization.builder.chart.bar': '柱状图', + 'visualization.builder.chart.line': '折线图', + 'visualization.builder.chart.pie': '饼图', + 'visualization.builder.dimension': '维度', + 'visualization.builder.metric': '指标', + 'visualization.builder.metric.count': '计数', + 'visualization.builder.aggregation': '聚合', + 'visualization.builder.aggregation.sum': '求和', + 'visualization.builder.aggregation.avg': '平均', + 'visualization.builder.aggregation.min': '最小值', + 'visualization.builder.aggregation.max': '最大值', + 'visualization.builder.errorNoData': '没有可用于可视化的数据,请更换维度或指标。', + 'visualization.builder.open': '打开可视化', + 'visualization.builder.titleCountBy': '{dimension} 的计数', + 'visualization.builder.titleAggregatedBy': '{dimension} 的 {aggregation}({metric})', + + 'table.copyColumn': '复制', + 'table.copyRow': '复制行', + 'table.emptyRows': '0 行。', + + 'jsonTree.collapseNode': '折叠节点', + 'jsonTree.expandNode': '展开节点', + 'jsonTree.summaryItems': '... 共 {count} 项 ...', + 'jsonTree.summaryObjectFields': '... 对象({count} 个字段)...', + + 'mongo.itemLabel': '文档', + 'mongo.moreFields': '+{count} 个更多字段', + 'mongo.copyDocument': '复制文档', + 'mongo.documentStructure': '文档结构', + 'mongo.noDocumentDetails': '暂无文档详情。', + 'mongo.rawJson': '原始 JSON', + 'mongo.emptyDocuments': '0 条文档。', + + 'theme.light': '浅色', + 'theme.dark': '深色', + 'theme.switchToTheme': '切换到 {theme} 主题', + + 'titleBar.ai': 'AI', + 'titleBar.minimize': '最小化', + 'titleBar.maximize': '最大化', + 'titleBar.close': '关闭', + + 'validation.nameRequired': '名称不能为空。', + 'validation.modelRequired': '模型不能为空。', + 'validation.typeRequired': '类型不能为空。', + 'validation.hostRequired': '主机不能为空。', + 'validation.portRequired': '端口不能为空。', + 'validation.chromadbSchemeInvalid': 'ChromaDB 协议必须是 http 或 https。', + 'validation.regionRequired': 'Region 不能为空。', + 'validation.usernameRequired': '用户名不能为空。', + 'validation.mongoHostsFormat': 'Mongo hosts 必须使用 host:port 格式。', + 'validation.secretProviderRequired': '引用已有密钥时必须选择密钥提供方。', + 'validation.secretKeyRequired': '引用已有密钥时必须填写密钥路径 / Key。', + 'validation.d1OauthRequired': 'D1 需要先完成 OAuth 登录。', + 'validation.d1ModeRequired': 'D1 模式不能为空。', + 'validation.d1DatabaseIdRequired': 'D1 databaseId 不能为空。', + 'validation.d1DatabaseNameRequired': 'D1 databaseName 不能为空。', + 'validation.d1BindingRequired': '本地模式下 D1 binding 不能为空。', + 'validation.d1AccountIdRequired': '云端模式下 D1 accountId 不能为空。', + 'validation.d1AuthModeInvalid': 'D1 auth mode 必须是 wrangler 或 token。', + 'validation.d1ApiTokenRequired': 'auth mode 为 token 时,D1 API token 不能为空。', + 'validation.d1CreateDatabaseNameRequired': 'D1 数据库名称不能为空。', + 'validation.dynamoProfileRequired': 'DynamoDB profile 不能为空。', + 'validation.dynamoSSOLoginRequired': '请先完成 AWS SSO 登录。', + 'validation.dynamoSSOAccountRequired': '请选择 AWS 账号。', + 'validation.dynamoSSORoleRequired': '请选择 AWS 角色。', + 'validation.dynamoSSOCredentialsRequired': '缺少 AWS 角色凭证,请先点击“OAuth 授权”。', + 'validation.sqlUriRequired': 'Direct URL 不能为空。', + 'validation.optionsJson': 'Options 必须是有效 JSON。', + 'validation.providerRequired': 'Provider 不能为空。', + 'validation.apiKeyRequired': 'API key 不能为空。', + 'validation.baseUrlRequiredForCustomProvider': '自定义 provider 必须填写 Base URL。', + + 'context.askAi': 'AI 询问', + 'context.smartAssistant': '智能助手', + 'context.aiSuggestions': 'AI 建议', + 'context.customPlaceholder': '输入自定义问题...', + 'context.enterToSend': '回车发送', + 'context.executeSelection': '执行选中内容', + 'context.copySnippet': '复制片段', + 'context.viewHistory': '查看历史', + 'context.redisCommandHelp': 'Redis 命令帮助', + 'context.redisCommandHelpDesc': '解释这个 Redis 命令做什么以及如何使用。', + 'context.explainLogic': '解释逻辑', + 'context.explainLogicDesc': '用通俗语言说明这条查询会返回什么。', + 'context.optimizePerformance': '优化性能', + 'context.optimizePerformanceDesc': '建议索引和查询优化方式。', + 'context.debugError': '调试错误', + 'context.debugErrorDesc': '分析潜在语法问题。', + 'context.execute': '执行', + 'context.copyCommand': '复制命令', + 'context.sendMessage': '发送消息', + + 'kb.title': '我的知识库', + 'kb.subtitle': '上传你的数据相关文档(schema 备注、字段的解释),让AI更加了解你的数据。', + 'kb.refresh': '刷新', + 'kb.newCategory': '新建分类', + 'kb.providerNotReady': '尚无可用AI配置。请先配置 AI 设置,以便上传后自动生成摘要。', + 'kb.providerMessage.runtimeUnavailable': 'Wails 运行时不可用。请在 Wails 环境中运行以启用后端操作。', + 'kb.goAiSettings': '前往 AI 设置', + + 'kb.categories.title': '分类', + 'kb.categories.countTitle': '{count} 个分类', + 'kb.categories.subtitle': '按主题/范围组织上传文件。', + 'kb.categories.new': '新建', + 'kb.categories.empty': '暂无分类,先创建一个再上传。', + + 'kb.selectCategory': '请选择一个分类查看文件。', + 'kb.unknown': '-', + 'kb.scope.label': '范围:', + 'kb.edit': '编辑', + 'kb.delete': '删除', + + 'kb.upload.title': '上传文件', + 'kb.upload.supported': '支持:PDF、Word(.docx)、Markdown、Text。', + 'kb.upload.action': '上传', + 'kb.upload.none': '未选择文件。', + 'kb.upload.selected': '已选择 {count} 个文件。', + + 'kb.files.title': '文件', + 'kb.files.subtitle': '解析文本会被索引用于 search_knowledge。上传后会自动生成 AI 摘要。', + 'kb.files.empty': '该分类下暂无文件。', + 'kb.files.parse': '解析', + 'kb.files.summary': '摘要', + + 'kb.dialog.newCategory': '新建分类', + 'kb.dialog.editCategory': '编辑分类', + 'kb.dialog.categorySubtitle': '分类可为全局或数据源范围(支持绑定多个数据源)。', + 'kb.dialog.name': '名称', + 'kb.dialog.description': '描述', + 'kb.dialog.scope': '范围', + 'kb.dialog.scopeAll': '全部(全局)', + 'kb.dialog.scopeDatasource': '数据源', + 'kb.dialog.bindDatasources': '绑定数据源', + 'kb.dialog.noDatasources': '未找到数据源,请先创建数据源。', + 'kb.dialog.cancel': '取消', + 'kb.dialog.save': '保存', + + 'kb.dialog.deleteCategoryTitle': '删除分类', + 'kb.dialog.deleteCategorySubtitle': '此操作会删除该分类下所有文件。', + 'kb.dialog.deleteFileTitle': '删除文件', + 'kb.dialog.deleteFileSubtitle': '此操作不可撤销。', + + 'kb.notice.nameRequired': '名称不能为空。', + 'kb.notice.scopeDatasourceRequired': '数据源范围至少选择一个数据源。', + 'kb.notice.categoryCreated': '分类已创建。', + 'kb.notice.categoryUpdated': '分类已更新。', + 'kb.notice.uploaded': '上传完成。', + 'kb.notice.categoryDeleted': '分类已删除。', + 'kb.notice.fileDeleted': '文件已删除。', + + 'kb.file.readFailed': '文件读取失败。', + 'kb.file.invalidEncoding': '文件编码无效。', + + 'kb.scope.datasource.single': '数据源', + 'kb.scope.datasource.multiple': '数据源 ×{count}', + 'kb.scope.all': '全部', + 'kb.scope.datasource.title': '数据源({ids})', + 'kb.scope.datasource.titleNoIds': '数据源', + 'kb.scope.all.title': '全部', + + 'kb.summary.pendingProvider': 'AI 摘要待处理(请先配置 AI 提供商)。', + 'kb.summary.queued': 'AI 摘要排队中。', + 'kb.summary.failed': 'AI 摘要失败。', + 'kb.summary.failedWithMessage': 'AI 摘要失败:{message}', + 'kb.summary.skipped': 'AI 摘要已跳过。', + 'kb.summary.skippedWithMessage': 'AI 摘要已跳过:{message}', + + 'sensitivity.title': '字段敏感分级', + 'sensitivity.infoBanner': '对数据库字段进行敏感度分级。分级完成后,敏感字段在通过 MCP/Skill 集成传输数据时可被脱敏或哈希处理,防止意外泄露。', + 'sensitivity.selectProvider': '选择 AI 供应商', + 'sensitivity.customRules': '自定义分级规则', + 'sensitivity.customRulesHint': '描述哪些字段属于敏感数据。这些规则会保存下来,供所有数据源扫描时使用。', + 'sensitivity.customRulesPlaceholder': '例如:包含 "phone"、"mobile"、"wechat" 的字段属于 PII 联系方式。以 "_id" 结尾且关联用户的字段属于标识符。"salary" 和 "bonus" 列属于金融数据。', + 'sensitivity.scan': '扫描', + 'sensitivity.scanning': '扫描中...', + 'sensitivity.scanComplete': '扫描完成', + 'sensitivity.scanFailed': '扫描失败:{message}', + 'sensitivity.customRulesLoadFailed': '加载自定义规则失败,请重试。', + 'sensitivity.noReport': '暂无分级报告。请选择 AI 供应商并运行扫描以分级字段。', + 'sensitivity.noSchema': '无 schema 缓存 — 请先打开该数据源的控制台。', + 'sensitivity.allEntitiesSkipped': '所有实体均被跳过 — 无字段详情。请先在控制台中打开并查看表结构。', + 'sensitivity.autoDescribing': '正在自动获取表结构,扫描即将开始,请稍候。', + 'sensitivity.lastScanned': '上次扫描:{time}', + 'sensitivity.entity': '实体', + 'sensitivity.field': '字段', + 'sensitivity.level': '敏感等级', + 'sensitivity.category': '分类', + 'sensitivity.reason': '理由', + 'sensitivity.source': '来源', + 'sensitivity.confirm': '确认', + 'sensitivity.override': '覆盖', + 'sensitivity.level.critical': '极高', + 'sensitivity.level.high': '高', + 'sensitivity.level.medium': '中', + 'sensitivity.level.low': '低', + 'sensitivity.level.unconfirmed': '待确认', + 'sensitivity.category.pii': '个人信息', + 'sensitivity.category.credential': '凭证', + 'sensitivity.category.financial': '金融', + 'sensitivity.category.behavioral': '行为', + 'sensitivity.category.medical': '医疗', + 'sensitivity.category.location': '位置', + 'sensitivity.category.contact': '联系方式', + 'sensitivity.category.identifier': '标识符', + 'sensitivity.category.none': '无', + 'sensitivity.source.ai': 'AI', + 'sensitivity.source.agent': 'Agent', + 'sensitivity.source.manual': '手动', + 'sensitivity.mode': '模式', + 'sensitivity.mode.whitelist': '白名单', + 'sensitivity.mode.blacklist': '黑名单', + 'sensitivity.mode.whitelistDesc': '所有字段默认视为敏感,除非被分级为低。', + 'sensitivity.mode.blacklistDesc': '所有字段默认视为非敏感,除非被分级为中或以上。', + 'sensitivity.progress': '{scanned}/{total} 实体', + 'sensitivity.fieldCount': '{count} 个字段', + 'sensitivity.deleteConfirm': '删除该数据源的所有分级结果?', + 'sensitivity.deleted': '分级结果已删除。', + 'sensitivity.statusPending': '待扫描', + 'sensitivity.statusScanning': '扫描中', + 'sensitivity.statusDone': '已完成', + 'sensitivity.statusSkipped': '已跳过', + 'sensitivity.scanStatus': '状态', + + 'sensitivityList.subtitle': '扫描并分类各数据源的字段敏感度等级', + 'sensitivityList.noDatasources': '暂无数据源', + 'sensitivityList.scanned': '已扫描', + 'sensitivityList.unscanned': '未扫描', + + 'my.sensitivity.title': '敏感度分级配置', + 'my.sensitivity.desc': '配置敏感度级别定义和AI Agent访问范围。', + 'my.sensitivity.accessSensitivity': 'AI Agent 访问敏感度', + 'my.sensitivity.noRestriction': '无限制', + 'my.sensitivity.accessFrom': '起始', + 'my.sensitivity.accessTo': '截止', + 'my.sensitivity.examples': '添加示例...', + 'my.sensitivity.editExamples': '编辑示例', + 'my.sensitivity.editExamplesPrompt': '编辑示例(逗号分隔):', + 'my.sensitivity.pickColor': '选择颜色', + 'my.sensitivity.examplesHint': '这些字段名示例在 AI 分类时作为参考依据提供给模型。', + 'my.sensitivity.levelKey': '键', + 'my.sensitivity.levelName': '级别名称', + 'my.sensitivity.levelDesc': '级别描述', + 'my.sensitivity.addLevel': '新增级别', + 'my.sensitivity.save': '保存', + 'my.sensitivity.resetDefaults': '恢复默认', + 'my.sensitivity.saved': '敏感度配置已保存。', + 'my.sensitivity.resetSuccess': '敏感度级别已恢复默认。', + 'my.sensitivityScan.title': '敏感度分级', + 'my.sensitivityScan.desc': '选择多个数据源,批量执行敏感度分级。', + 'my.sensitivityScan.aiConfig': 'AI 配置', + 'my.sensitivityScan.noDatasources': '未找到数据源,请先添加数据源。', + 'my.sensitivityScan.selectAll': '全选', + 'my.sensitivityScan.startScan': '开始分级', + 'my.sensitivityScan.scanning': '分级中...', + 'my.sensitivityScan.stop': '停止', + 'my.sensitivityScan.queued': '排队中', + 'my.sensitivityScan.completed': '已完成', + 'my.sensitivityScan.failed': '失败', + 'my.sensitivityScan.retry': '重试', + 'sensitivity.levelDef.L1.name': '公开', + 'sensitivity.levelDef.L1.desc': '非敏感运营数据', + 'sensitivity.levelDef.L2.name': '内部', + 'sensitivity.levelDef.L2.desc': '内部标识符和元数据', + 'sensitivity.levelDef.L3.name': '机密', + 'sensitivity.levelDef.L3.desc': '间接PII、行为和位置数据', + 'sensitivity.levelDef.L4.name': '敏感', + 'sensitivity.levelDef.L4.desc': '直接PII、财务和医疗数据', + 'sensitivity.levelDef.L5.name': '关键', + 'sensitivity.levelDef.L5.desc': '凭据、支付工具和高度敏感个人数据', + + 'route.sensitivity': '字段敏感分级', + + 'riskRules.title': '风控规则', + 'riskRules.subtitle': '为所有数据源类型配置风险评估规则。', + 'riskRules.tabs.rules': '规则', + 'riskRules.tabs.trustLevels': '信任模式', + 'sensitivity.schemaEgress.title': 'AI 获取 Schema 设置', + 'sensitivity.schemaEgress.desc': '控制 AI Chat 工具、ER 图生成器、敏感字段扫描是否允许把该数据源的 schema 元数据(表名、字段名、字段类型、注释、索引)发送给你配置的 AI provider,或者基于 Skill/MCP 连接的 Agent 能否获取你的数据源的 schema 元数据。', + 'sensitivity.schemaEgress.distinction.title': '与结果脱敏有什么区别?', + 'sensitivity.schemaEgress.distinction.body': '结果脱敏只对查询结果中的字段值进行屏蔽。Schema 元数据(表名、字段名、注释、索引)是另一条出境路径——即使结果被脱敏,发送 schema 仍会暴露业务结构。本设置专门管控这条路径。', + 'sensitivity.schemaEgress.empty': '当前没有任何数据源。', + 'sensitivity.schemaEgress.lastSentAt': '上次发送 {time} · {status}', + 'sensitivity.schemaEgress.neverSent': '尚未发送过 schema 元数据。', + 'sensitivity.schemaEgress.consent.unset.label': '未设置', + 'sensitivity.schemaEgress.consent.unset.desc': '默认。在你显式允许之前,schema 元数据不会被发出。', + 'sensitivity.schemaEgress.consent.allowed.label': '允许', + 'sensitivity.schemaEgress.consent.allowed.desc': 'AI 工具、ER 生成、敏感扫描,以及 Skill/MCP 连接的 Agent 都可以读取或发送 schema 元数据。每次访问都会写入下方的审计记录。', + 'sensitivity.schemaEgress.consent.denied.label': '拒绝', + 'sensitivity.schemaEgress.consent.denied.desc': '与未设置等价,但属于显式拒绝。当你已审阅并明确不允许此数据源外发时使用。', + 'sensitivity.schemaEgress.consent.groupLabel': '{name} 的 schema 出境授权', + 'sensitivity.schemaEgress.trigger.ai_chat_describe_entity': 'AI Chat · 查看实体结构', + 'sensitivity.schemaEgress.trigger.ai_chat_list_entities': 'AI Chat · 列出实体', + 'sensitivity.schemaEgress.trigger.ai_chat_get_schema_knowledge': 'AI Chat · 读取 schema 知识', + 'sensitivity.schemaEgress.trigger.ai_chat_get_er_knowledge': 'AI Chat · 读取 ER 知识', + 'sensitivity.schemaEgress.trigger.schema_knowledge_er_generation': 'ER 图生成', + 'sensitivity.schemaEgress.trigger.sensitivity_scan': '敏感字段扫描', + 'sensitivity.schemaEgress.trigger.mcp_list_entities': 'Skill/MCP · 列出实体', + 'sensitivity.schemaEgress.trigger.mcp_describe_entity': 'Skill/MCP · 查看实体结构', + 'sensitivity.schemaEgress.trigger.mcp_get_schema_knowledge': 'Skill/MCP · 读取 schema 知识', + 'sensitivity.schemaEgress.trigger.mcp_get_er_knowledge': 'Skill/MCP · 读取 ER 知识', + 'sensitivity.schemaEgress.auditTitle': '最近发送记录', + 'sensitivity.schemaEgress.auditDesc': '最近 50 条放行/拒绝决策,包括被拒绝的尝试。持久化至 data/history/schema-llm-audit.jsonl。', + 'sensitivity.schemaEgress.auditEmpty': '暂无 schema 出境决策记录。', + 'sensitivity.schemaEgress.audit.time': '时间', + 'sensitivity.schemaEgress.audit.datasource': '数据源', + 'sensitivity.schemaEgress.audit.trigger': '触发源', + 'sensitivity.schemaEgress.audit.status': '状态', + 'sensitivity.schemaEgress.audit.entities': '实体数', + 'sensitivity.schemaEgress.audit.fields': '字段数', + 'sensitivity.schemaEgress.audit.provider': '服务商 · 模型', + 'sensitivity.schemaEgress.status.allowed': '已发送', + 'sensitivity.schemaEgress.status.denied': '已拒绝', + 'sensitivity.schemaEgress.status.unknown': '未知', + 'riskRules.trustLevels.title': '按数据源设置信任模式', + 'riskRules.trustLevels.desc': '该设置同时应用于 AI Chat、MCP/Skill 和 CLI 三条执行路径。AI Chat 可以请求你审批;第三方 Skill、MCP 和带 agent 访问密钥的 CLI 调用只要需要审批就会被拒绝。', + 'riskRules.trustLevels.empty': '当前没有任何数据源。', + 'riskRules.trustLevels.warningDanger': '危险模式会跳过该数据源上的所有审批和 block 规则,仅建议在可随时重建的开发/测试数据源使用。', + 'riskRules.trustLevels.confirmDanger': '确定将 "{name}" 切换到危险模式?\n\n危险模式将跳过该数据源上的所有审批和 block 规则。AI、MCP、CLI 工具会自动执行任何语句,包括被 block 规则命中的破坏性操作。\n\n仅当该数据源的数据可以随时重建时,才建议启用。', + 'riskRules.trustLevels.confirmDangerTitle': '切换到危险模式?', + 'riskRules.trustLevels.confirmDangerBody': '危险模式将跳过该数据源上的所有审批和 block 规则。AI、MCP、CLI 工具会自动执行任何语句,包括被 block 规则命中的破坏性操作。\n\n仅当该数据源的数据可以随时重建时,才建议启用。', + 'riskRules.trustLevels.confirmDangerOk': '启用危险模式', + 'riskRules.trustLevels.confirmDangerCancel': '取消', + 'riskRules.trustLevels.approval.label': '审批模式', + 'riskRules.trustLevels.approval.desc': '任何语句都需要在 AI Chat 或桌面控制台里由你确认;第三方 Agent 不会等待审批,而是直接被拒绝。', + 'riskRules.trustLevels.cautious.label': '谨慎模式', + 'riskRules.trustLevels.cautious.desc': '默认。仅自动执行低风险语句(通常是走到索引的查询);写入和不确定语句在 AI Chat 中需要审批,对第三方 Agent 直接拒绝。', + 'riskRules.trustLevels.trusted.label': '信任模式', + 'riskRules.trustLevels.trusted.desc': '自动执行低风险和中风险语句;高风险操作在 AI Chat 中需要审批,对第三方 Agent 直接拒绝。', + 'riskRules.trustLevels.danger.label': '危险模式', + 'riskRules.trustLevels.danger.desc': '自动执行一切,包括 block 规则命中的语句。仅建议用于可随时重建的数据源。', + 'riskRules.trustLevels.legacyNotice.title': 'AI 自动执行设置已迁移', + 'riskRules.trustLevels.legacyNotice.body': '你之前设置的自动执行风险等级({levels})是全局偏好,现已被按数据源的信任模式替代。请在下方为每个数据源选择一个合适的信任模式,以确保 AI、MCP 和 CLI 工具的行为与你的预期一致。', + 'riskRules.trustLevels.legacyNotice.bodyStrict': '你之前完全关闭了 AI 自动执行(不允许任何风险等级自动运行)。该全局偏好已被按数据源的信任模式替代。当前默认模式「谨慎」会自动运行低风险读取;若想保留之前的严格行为,请在下方为各数据源切换到「审批」。', + 'riskRules.trustLevels.legacyNotice.dismiss': '知道了', + 'riskRules.builtinSection': '内置规则', + 'riskRules.userSection': '自定义规则', + 'riskRules.newRule': '新建规则', + 'riskRules.import': '导入', + 'riskRules.export': '导出', + 'riskRules.filterAll': '全部', + 'riskRules.empty': '还没有自定义规则,创建你的第一条规则。', + 'riskRules.confirmDelete': '确定删除此规则?', + 'riskRules.enableRule': '启用规则', + 'riskRules.disableRule': '停用规则', + 'riskRules.autoManaged': '自动应用', + 'riskRules.importTitle': '导入规则', + 'riskRules.importHint': '在下方粘贴规则的 JSON 数组。', + 'riskRules.importTextareaLabel': '导入规则 JSON', + 'riskRules.importBtn': '导入', + 'riskRules.importSuccess': '已导入 {count} 条规则。', + 'riskRules.importError': 'JSON 格式无效。', + 'riskRules.triggerLabel': '触发条件:', + 'riskRules.exportTitle': '导出规则', + 'riskRules.exportHint': '复制以下 JSON 以保存自定义规则。', + 'riskRules.exportTextareaLabel': '导出规则 JSON', + 'riskRules.action.block': '阻止', + 'riskRules.action.require_approval': '需要审批', + 'riskRules.action.warn': '警告', + 'riskRules.action.allow': '允许', + 'riskRules.action.block.desc': '拒绝执行', + 'riskRules.action.require_approval.desc': 'AI Chat 请求审批;第三方 Agent 直接拒绝', + 'riskRules.action.warn.desc': '确认后执行', + 'riskRules.action.allow.desc': '直接放行', + 'riskRules.form.sectionAction': '动作', + 'riskRules.form.sectionScope': '作用范围', + 'riskRules.form.sectionCondition': '触发条件', + 'riskRules.form.sectionThresholds': '查询安全阈值', + 'riskRules.form.sectionInfo': '描述与优先级', + 'riskRules.form.editBuiltinTitle': '编辑内置规则 {code}', + 'riskRules.form.builtinBehaviorTitle': '规则检查内容', + 'riskRules.form.builtinThresholdsTitle': '可调整阈值', + 'riskRules.form.builtinReadonly': '这条内置规则不可编辑。', + 'riskRules.form.dsTypes': '数据源类型', + 'riskRules.form.datasource': '指定数据源', + 'riskRules.form.datasourceAny': '任意', + 'riskRules.form.entity': '目标表 / 实体', + 'riskRules.form.entityHint': '从已连接的数据源选择表,或手动输入名称。', + 'riskRules.form.entityManual': '手动输入', + 'riskRules.form.entityFromDs': '从数据源选择', + 'riskRules.form.entityLoading': '正在加载表...', + 'riskRules.form.entityEmpty': '未找到表。', + 'riskRules.form.entitySearch': '搜索表...', + 'riskRules.form.entityPickTitle': '选择表 / 实体', + 'riskRules.form.entitySelected': '已选', + 'riskRules.form.entityDone': '完成', + 'riskRules.form.entityManualPlaceholder': '例如 orders, public.users', + 'riskRules.form.selectAll': '全选', + 'riskRules.form.deselectAll': '取消全选', + 'riskRules.form.clearAll': '清除全部', + 'riskRules.form.sqlCommands': '语句类型', + 'riskRules.form.whereClause': 'WHERE 子句', + 'riskRules.form.whereAny': '任意', + 'riskRules.form.whereRequired': '必须有 WHERE', + 'riskRules.form.whereNone': '必须没有 WHERE', + 'riskRules.form.redisCategory': '命令类别', + 'riskRules.form.redisWildcard': '* (所有命令)', + 'riskRules.form.redisWildcardHint': '匹配所有 Redis 命令,请谨慎使用。', + 'riskRules.form.redisSpecific': '或指定命令', + 'riskRules.form.redisSpecificHint': '逗号分隔,如 DEL, SET, *', + 'riskRules.form.redisBrowseAll': '浏览全部…', + 'riskRules.form.redisPickTitle': '选择 Redis 命令', + 'riskRules.form.redisPickSearch': '搜索命令…', + 'riskRules.form.redisPickSelected': '已选', + 'riskRules.form.redisPickLoading': '正在加载 Redis 命令目录…', + 'riskRules.form.redisPickClear': '清空', + 'riskRules.form.redisPickApply': '应用选择', + 'riskRules.form.keyPattern': 'Key 匹配', + 'riskRules.form.keyPatternHint': '* 匹配任意,如 session:*, user:*', + 'riskRules.form.mongoOps': '操作', + 'riskRules.form.esMethod': 'HTTP 方法', + 'riskRules.form.esPath': '路径包含', + 'riskRules.form.esPathHint': '如 _search, _bulk, _delete_by_query', + 'riskRules.form.maxExaminedRows': '最大检查行数', + 'riskRules.form.allowSafeSeqScan': '允许安全的全表扫描', + 'riskRules.form.allowSafeSeqScanHint': '小表在未使用索引时,如果行数和成本在阈值内则允许。', + 'riskRules.form.seqScanRowsThreshold': '最大扫描行数', + 'riskRules.form.costThreshold': '成本阈值', + 'riskRules.form.maxJoinCount': '最大 JOIN 数', + 'riskRules.form.maxFullScans': '最大全表扫描数', + 'riskRules.form.maxDynamoDBPages': '最大 DynamoDB 页数', + 'riskRules.form.maxDynamoDBEvaluatedItems': '最大 DynamoDB 评估 item 数', + 'riskRules.form.maxEstimatedJoinRows': '最大预估 JOIN 行数', + 'riskRules.form.ruleName': '规则名称', + 'riskRules.form.ruleNameHint': '简要描述此规则的作用。', + 'riskRules.form.reason': '给用户的提示信息', + 'riskRules.form.reasonHint': '此消息会显示在确认对话框中。', + 'riskRules.form.priority': '优先级', + 'riskRules.form.priorityLow': '低', + 'riskRules.form.priorityMedium': '中', + 'riskRules.form.priorityHigh': '高', + 'riskRules.form.priorityHint': '优先级高的规则优先匹配。', + 'riskRules.form.preview': '规则预览', + 'riskRules.form.previewText': '此规则将对 {dsTypes} 中 {entity} 的 {commands} 操作执行 {action}。', + 'riskRules.form.saveError': '保存规则失败。', + 'riskRules.form.save': '保存规则', + 'riskRules.redis.write': '写入 (SET, DEL, HSET...)', + 'riskRules.redis.read': '读取 (GET, MGET, TYPE...)', + 'riskRules.redis.scan': '扫描 (KEYS, SCAN, HGETALL...)', + 'riskRules.redis.admin': '管理 (CONFIG, CLIENT...)', + 'riskRules.redis.script': '脚本 (EVAL, FCALL...)', + 'riskRules.builtin.PRB-001.title': '视图校验', + 'riskRules.builtin.PRB-001.summary': '检查 D1 视图在执行前是否能被正确解析和确认。', + 'riskRules.builtin.PRB-001.trigger': '目标是 D1 视图,且应用无法解析视图定义、读取视图信息,或无法从执行计划确认视图最终展开方式。', + 'riskRules.builtin.PRB-002.title': '执行路径校验', + 'riskRules.builtin.PRB-002.summary': '检查应用是否能相信 EXPLAIN 或类似探测结果返回的执行路径。', + 'riskRules.builtin.PRB-002.trigger': 'EXPLAIN 执行失败、没有返回可用结果,或声称用了索引却没有足够结构信息证明真实执行路径。', + 'riskRules.builtin.PRB-003.title': '未发现索引', + 'riskRules.builtin.PRB-003.summary': '检查执行计划是否显示命中索引,并允许对小表顺序扫描做例外处理。', + 'riskRules.builtin.PRB-003.trigger': '执行计划没有显示索引命中。如果开启了安全顺序扫描,且扫描行数与成本都在阈值内,小表顺序扫描可以不触发这条规则。', + 'riskRules.builtin.PRB-004.title': '扫描范围过大', + 'riskRules.builtin.PRB-004.summary': '检查语句预计会扫描多少行、键或文档。', + 'riskRules.builtin.PRB-004.trigger': '扫描行数、扫描键数或扫描文档数超过设定上限,适用于 SQL、D1 和 MongoDB 的探测结果。', + 'riskRules.builtin.PRB-005.title': '高成本执行计划', + 'riskRules.builtin.PRB-005.summary': '检查执行计划里是否出现 join 过多、扫描过重或其他高成本访问路径。', + 'riskRules.builtin.PRB-005.trigger': '计划里出现过多 join、过多全表扫描、过大的 join 预估,或临时表、filesort、物化、中间聚合 join、视图展开后扫描过重等高成本模式。', + 'riskRules.builtin.PRB-006.title': '缺少元数据', + 'riskRules.builtin.PRB-006.summary': '检查应用在评估 DynamoDB 风险前,是否拿到了所需元数据。', + 'riskRules.builtin.PRB-006.trigger': '风险判断所需的表元数据不可用。', + 'riskRules.builtin.PRB-007.title': '访问路径未确认', + 'riskRules.builtin.PRB-007.summary': '检查应用是否能根据现有 DynamoDB 元数据确认最终访问路径。', + 'riskRules.builtin.PRB-007.trigger': '读取现有元数据后,应用仍然无法确认访问路径。', + }, + ja: { + 'common.color.green': '緑', + 'common.color.blue': '青', + 'common.color.yellow': '黄', + 'common.color.orange': 'オレンジ', + 'common.color.red': '赤', + 'common.color.purple': '紫', + 'common.color.pink': 'ピンク', + 'common.color.gray': 'グレー', + 'nav.sources': 'データソース', + 'nav.history': '履歴', + 'nav.dataSensitivity': 'データ機密度分類', + 'nav.riskRules': 'リスクルール', + 'nav.aiSettings': 'AI設定', + 'nav.my': 'マイ', + + 'route.datasources': 'データソース', + 'route.datasourceCreate': '新しいデータソース', + 'route.datasourceEdit': 'データソースを編集', + 'route.console': 'コンソール', + 'route.history': '履歴', + 'route.sensitivityList': 'データ機密度分類', + 'route.aiSettings': 'AI設定', + 'route.aiSettingsCreate': '新しいAI設定', + 'route.aiSettingsEdit': 'AI設定を編集', + 'route.my': 'マイ', + 'route.default': 'ダッシュボード', + + 'my.menu.label': 'マイメニュー', + 'my.menu.knowledgeBase': 'ナレッジベース', + 'my.menu.language': '言語', + 'my.menu.chooseHint': '続行するメニュー項目を選択してください。', + + 'my.language.title': '言語', + 'my.language.desc': '優先するUI言語を選択します。Explain文言とコンテキストメニューにも適用されます。', + 'my.language.current': '現在の言語', + 'my.language.option.en': 'English', + 'my.language.option.zh': '中文', + 'my.language.option.ja': '日本語', + 'my.language.option.es': 'Español', + 'my.language.option.de': 'Deutsch', + 'my.language.selectLabel': '言語を選択', + 'my.language.saved': '言語を更新しました。', + + 'datasource.list.subtitle': 'データソースを管理します', + + 'history.subtitle': '実行履歴を確認します。', + 'history.searchPlaceholder': 'ステートメント、データソース、またはテーブル名で検索', + 'history.clearFiltered': '絞り込み結果を削除', + + 'visualization.subtitle': 'データを生き生きと可視化します。', + + 'ai.panel.subtitle': 'AI設定をカスタマイズします', + 'ai.panel.providers': 'AI設定一覧', + 'ai.panel.addProvider': '設定を追加', + 'ai.panel.empty': 'AI設定がまだありません。「設定を追加」をクリックして開始してください。', + 'ai.panel.noConnected': '接続済みのAI設定はありません。', + 'ai.panel.noFailed': '失敗したAI設定はありません。', + 'ai.panel.deleteTitle': 'AI設定を削除', + 'ai.panel.deleted': 'AI設定を削除しました', + + 'ai.form.titleEdit': 'AI設定を編集', + 'ai.form.titleCreate': 'AI設定を新規作成', + + 'ai.sidebar.analyzeRiskNote': + 'この操作では、結果行のサンプルを設定済みのAIプロバイダーへ送信してデータ分析を行います。共有してよい場合のみ承認してください。', + 'ai.sidebar.visualizationRiskNote': + 'この操作では、結果行のサンプルを設定済みのAIプロバイダーへ送信してデータ可視化を生成します。共有してよい場合のみ承認してください。', + 'ai.sidebar.noProvider': '利用可能なAI設定がありません。', + 'ai.sidebar.providerFallback': 'AI設定', + + 'kb.subtitle': + 'データ関連ドキュメント(スキーマメモ、フィールド説明)をアップロードして、AIがデータをより理解できるようにします。', + 'kb.providerNotReady': '利用可能なAI設定がありません。アップロード後に自動要約を有効にするには、先にAI設定を構成してください。', + + 'sensitivity.title': 'フィールド機密分類', + 'sensitivity.infoBanner': 'データベースフィールドの機密レベルを分類します。分類後、MCP/Skill連携でデータが流れる際に機密フィールドをマスクまたはハッシュ処理し、意図しない漏洩を防止できます。', + 'sensitivity.selectProvider': 'AIプロバイダーを選択', + 'sensitivity.customRules': 'カスタム分類ルール', + 'sensitivity.customRulesHint': 'どのフィールドが機密データかを記述してください。これらのルールは保存され、すべてのデータソースで再利用されます。', + 'sensitivity.customRulesPlaceholder': '例:「phone」「mobile」を含むフィールドはPII連絡先情報です。「_id」で終わるユーザー関連フィールドは識別子です。「salary」「bonus」列は金融データです。', + 'sensitivity.scan': 'スキャン', + 'sensitivity.scanning': 'スキャン中...', + 'sensitivity.scanComplete': 'スキャン完了', + 'sensitivity.scanFailed': 'スキャン失敗:{message}', + 'sensitivity.customRulesLoadFailed': 'カスタムルールの読み込みに失敗しました。もう一度お試しください。', + 'sensitivity.noReport': '分類レポートはまだありません。AIプロバイダーを選択してスキャンを実行してください。', + 'sensitivity.noSchema': 'スキーマキャッシュがありません。先にこのデータソースのコンソールを開いてください。', + 'sensitivity.allEntitiesSkipped': 'すべてのエンティティがスキップされました。コンソールでテーブル構造を確認してください。', + 'sensitivity.autoDescribing': 'スキャン前にテーブルスキーマを自動取得中です。しばらくお待ちください。', + 'sensitivity.lastScanned': '最終スキャン:{time}', + 'sensitivity.entity': 'エンティティ', + 'sensitivity.field': 'フィールド', + 'sensitivity.level': '機密レベル', + 'sensitivity.category': 'カテゴリ', + 'sensitivity.reason': '理由', + 'sensitivity.source': 'ソース', + 'sensitivity.confirm': '確認', + 'sensitivity.override': 'オーバーライド', + 'sensitivity.level.critical': '最高', + 'sensitivity.level.high': '高', + 'sensitivity.level.medium': '中', + 'sensitivity.level.low': '低', + 'sensitivity.level.unconfirmed': '未確認', + 'sensitivity.category.pii': '個人情報', + 'sensitivity.category.credential': '認証情報', + 'sensitivity.category.financial': '金融', + 'sensitivity.category.behavioral': '行動', + 'sensitivity.category.medical': '医療', + 'sensitivity.category.location': '位置情報', + 'sensitivity.category.contact': '連絡先', + 'sensitivity.category.identifier': '識別子', + 'sensitivity.category.none': 'なし', + 'sensitivity.source.ai': 'AI', + 'sensitivity.source.agent': 'エージェント', + 'sensitivity.source.manual': '手動', + 'sensitivity.mode': 'モード', + 'sensitivity.mode.whitelist': 'ホワイトリスト', + 'sensitivity.mode.blacklist': 'ブラックリスト', + 'sensitivity.mode.whitelistDesc': 'すべてのフィールドは、低と分類されない限りデフォルトで機密とみなされます。', + 'sensitivity.mode.blacklistDesc': 'すべてのフィールドは、中以上に分類されない限りデフォルトで非機密とみなされます。', + 'sensitivity.progress': '{scanned}/{total} エンティティ', + 'sensitivity.fieldCount': '{count} フィールド', + 'sensitivity.deleteConfirm': 'このデータソースのすべての分類結果を削除しますか?', + 'sensitivity.deleted': '分類結果を削除しました。', + 'sensitivity.statusPending': '未スキャン', + 'sensitivity.statusScanning': 'スキャン中', + 'sensitivity.statusDone': '完了', + 'sensitivity.statusSkipped': 'スキップ', + 'sensitivity.scanStatus': 'ステータス', + + 'sensitivityList.subtitle': 'データソース全体のフィールド機密度をスキャン・分類', + 'sensitivityList.noDatasources': 'データソースがありません', + 'sensitivityList.scanned': 'スキャン済み', + 'sensitivityList.unscanned': '未スキャン', + + 'my.sensitivity.title': '機密レベル設定', + 'my.sensitivity.desc': '機密レベルの定義とAIエージェントのアクセス範囲を設定します。', + 'my.sensitivity.accessSensitivity': 'AI Agent アクセス機密度', + 'my.sensitivity.noRestriction': '制限なし', + 'my.sensitivity.accessFrom': '開始', + 'my.sensitivity.accessTo': '終了', + 'my.sensitivity.examples': '例を追加...', + 'my.sensitivity.editExamples': '例を編集', + 'my.sensitivity.editExamplesPrompt': '例を編集(カンマ区切り):', + 'my.sensitivity.pickColor': '色を選択', + 'my.sensitivity.examplesHint': 'これらのフィールド名の例は、AIがスキーマを分類する際の参考として提供されます。', + 'my.sensitivity.levelKey': 'キー', + 'my.sensitivity.levelName': 'レベル名', + 'my.sensitivity.levelDesc': 'レベル説明', + 'my.sensitivity.addLevel': 'レベル追加', + 'my.sensitivity.save': '保存', + 'my.sensitivity.resetDefaults': 'デフォルトに戻す', + 'my.sensitivity.saved': '機密設定を保存しました。', + 'my.sensitivity.resetSuccess': '機密レベルをデフォルトに戻しました。', + 'my.sensitivityScan.title': '機密スキャン', + 'my.sensitivityScan.desc': '複数のデータソースを選択して、一括で機密分類を実行します。', + 'my.sensitivityScan.aiConfig': 'AI設定', + 'my.sensitivityScan.noDatasources': 'データソースが見つかりません。先にデータソースを追加してください。', + 'my.sensitivityScan.selectAll': 'すべて選択', + 'my.sensitivityScan.startScan': 'スキャン開始', + 'my.sensitivityScan.scanning': 'スキャン中...', + 'my.sensitivityScan.stop': '停止', + 'my.sensitivityScan.queued': '待機中', + 'my.sensitivityScan.completed': '完了', + 'my.sensitivityScan.failed': '失敗', + 'sensitivity.levelDef.L1.name': '公開', + 'sensitivity.levelDef.L1.desc': '非機密運用データ', + 'sensitivity.levelDef.L2.name': '内部', + 'sensitivity.levelDef.L2.desc': '内部識別子とメタデータ', + 'sensitivity.levelDef.L3.name': '機密', + 'sensitivity.levelDef.L3.desc': '間接PII、行動・位置データ', + 'sensitivity.levelDef.L4.name': '敏感', + 'sensitivity.levelDef.L4.desc': '直接PII、財務・医療データ', + 'sensitivity.levelDef.L5.name': '重要', + 'sensitivity.levelDef.L5.desc': '認証情報、決済手段、高度機密個人データ', + + 'route.sensitivity': 'フィールド機密分類', + }, + es: { + 'common.color.green': 'Verde', + 'common.color.blue': 'Azul', + 'common.color.yellow': 'Amarillo', + 'common.color.orange': 'Naranja', + 'common.color.red': 'Rojo', + 'common.color.purple': 'Púrpura', + 'common.color.pink': 'Rosa', + 'common.color.gray': 'Gris', + 'nav.sources': 'Fuentes', + 'nav.history': 'Historial', + 'nav.dataSensitivity': 'Sensibilidad de datos', + 'nav.riskRules': 'Reglas de riesgo', + 'nav.aiSettings': 'Ajustes de IA', + 'nav.my': 'Mi espacio', + + 'route.datasources': 'Fuentes de datos', + 'route.datasourceCreate': 'Nueva fuente de datos', + 'route.datasourceEdit': 'Editar fuente de datos', + 'route.console': 'Consola', + 'route.history': 'Historial', + 'route.sensitivityList': 'Sensibilidad de datos', + 'route.aiSettings': 'Ajustes de IA', + 'route.aiSettingsCreate': 'Nueva configuración de IA', + 'route.aiSettingsEdit': 'Editar configuración de IA', + 'route.my': 'Mi espacio', + 'route.default': 'Panel', + + 'my.menu.label': 'Menú personal', + 'my.menu.knowledgeBase': 'Base de conocimiento', + 'my.menu.language': 'Idioma', + 'my.menu.chooseHint': 'Elige un elemento del menú para continuar.', + + 'my.language.title': 'Idioma', + 'my.language.desc': 'Elige tu idioma de interfaz preferido. Esto también aplica al texto de Explain y a los menús contextuales.', + 'my.language.current': 'Idioma actual', + 'my.language.option.en': 'English', + 'my.language.option.zh': '中文', + 'my.language.option.ja': '日本語', + 'my.language.option.es': 'Español', + 'my.language.option.de': 'Deutsch', + 'my.language.selectLabel': 'Elegir idioma', + 'my.language.saved': 'Idioma actualizado.', + + 'datasource.list.subtitle': 'Administra tus fuentes de datos.', + + 'history.subtitle': 'Consulta el historial de ejecución.', + 'history.searchPlaceholder': 'Buscar por sentencia, fuente de datos o tabla', + 'history.clearFiltered': 'Eliminar resultados filtrados', + + 'visualization.subtitle': 'Haz que tus datos cobren vida.', + + 'ai.panel.subtitle': 'Personaliza las configuraciones de IA.', + 'ai.panel.providers': 'Lista de configuraciones de IA', + 'ai.panel.addProvider': 'Nueva configuración', + 'ai.panel.empty': 'Aún no hay configuraciones de IA. Haz clic en "Nueva configuración" para empezar.', + 'ai.panel.noConnected': 'No hay configuraciones de IA conectadas.', + 'ai.panel.noFailed': 'No hay configuraciones de IA con errores.', + 'ai.panel.deleteTitle': 'Eliminar configuración de IA', + 'ai.panel.deleted': 'Configuración de IA eliminada', + + 'ai.form.titleEdit': 'Editar configuración de IA', + 'ai.form.titleCreate': 'Nueva configuración de IA', + + 'ai.sidebar.analyzeRiskNote': + 'Esta acción enviará una muestra de filas de resultados a tu proveedor de IA configurado para análisis de datos. Aprueba solo si aceptas compartir estos datos.', + 'ai.sidebar.visualizationRiskNote': + 'Esta acción enviará una muestra de filas de resultados a tu proveedor de IA configurado para generar visualizaciones de datos. Aprueba solo si aceptas compartir estos datos.', + 'ai.sidebar.noProvider': 'No hay configuraciones de IA disponibles.', + 'ai.sidebar.providerFallback': 'Configuración de IA', + + 'kb.subtitle': + 'Sube documentos relacionados con tus datos (notas de esquema, explicación de campos) para que la IA entienda mejor tus datos.', + 'kb.providerNotReady': 'No hay una configuración de IA disponible. Configura Ajustes de IA para habilitar resúmenes automáticos tras la carga.', + + 'sensitivity.title': 'Sensibilidad de campos', + 'sensitivity.infoBanner': 'Clasifica los campos de tu base de datos por nivel de sensibilidad. Una vez clasificados, los campos sensibles pueden enmascararse o procesarse con hash en las integraciones MCP/Skill para proteger contra exposiciones accidentales.', + 'sensitivity.selectProvider': 'Seleccionar proveedor de IA', + 'sensitivity.customRules': 'Reglas de clasificación personalizadas', + 'sensitivity.customRulesHint': 'Describe qué campos son datos sensibles. Estas reglas se guardan y se reutilizan en todas las fuentes de datos.', + 'sensitivity.customRulesPlaceholder': 'Ej: Los campos con "phone", "mobile" son información de contacto PII. Los campos que terminan en "_id" son identificadores. Las columnas "salary" y "bonus" son datos financieros.', + 'sensitivity.scan': 'Escanear', + 'sensitivity.scanning': 'Escaneando...', + 'sensitivity.scanComplete': 'Escaneo completo', + 'sensitivity.scanFailed': 'Escaneo fallido: {message}', + 'sensitivity.customRulesLoadFailed': 'Error al cargar las reglas personalizadas. Inténtalo de nuevo.', + 'sensitivity.noReport': 'Aún no hay informe. Selecciona un proveedor de IA y ejecuta un escaneo para clasificar los campos.', + 'sensitivity.noSchema': 'No hay caché de esquema disponible — abre primero la consola de esta fuente de datos.', + 'sensitivity.allEntitiesSkipped': 'Todas las entidades fueron omitidas — no hay detalles de campos. Abre y describe las tablas en la consola primero.', + 'sensitivity.autoDescribing': 'Obteniendo esquemas de tablas automáticamente antes del escaneo. Esto puede tardar un momento.', + 'sensitivity.lastScanned': 'Último escaneo: {time}', + 'sensitivity.entity': 'Entidad', + 'sensitivity.field': 'Campo', + 'sensitivity.level': 'Nivel de sensibilidad', + 'sensitivity.category': 'Categoría', + 'sensitivity.reason': 'Razón', + 'sensitivity.source': 'Fuente', + 'sensitivity.confirm': 'Confirmar', + 'sensitivity.override': 'Anular', + 'sensitivity.level.critical': 'Crítico', + 'sensitivity.level.high': 'Alto', + 'sensitivity.level.medium': 'Medio', + 'sensitivity.level.low': 'Bajo', + 'sensitivity.level.unconfirmed': 'Sin confirmar', + 'sensitivity.category.pii': 'Datos personales', + 'sensitivity.category.credential': 'Credencial', + 'sensitivity.category.financial': 'Financiero', + 'sensitivity.category.behavioral': 'Comportamiento', + 'sensitivity.category.medical': 'Médico', + 'sensitivity.category.location': 'Ubicación', + 'sensitivity.category.contact': 'Contacto', + 'sensitivity.category.identifier': 'Identificador', + 'sensitivity.category.none': 'Ninguno', + 'sensitivity.source.ai': 'IA', + 'sensitivity.source.agent': 'Agente', + 'sensitivity.source.manual': 'Manual', + 'sensitivity.mode': 'Modo', + 'sensitivity.mode.whitelist': 'Lista blanca', + 'sensitivity.mode.blacklist': 'Lista negra', + 'sensitivity.mode.whitelistDesc': 'Todos los campos se consideran sensibles por defecto, excepto los clasificados como bajo.', + 'sensitivity.mode.blacklistDesc': 'Todos los campos se consideran no sensibles por defecto, excepto los clasificados como medio o superior.', + 'sensitivity.progress': '{scanned}/{total} entidades', + 'sensitivity.fieldCount': '{count} campos', + 'sensitivity.deleteConfirm': '¿Eliminar todos los resultados de clasificación de esta fuente de datos?', + 'sensitivity.deleted': 'Resultados de clasificación eliminados.', + 'sensitivity.statusPending': 'Pendiente', + 'sensitivity.statusScanning': 'Escaneando', + 'sensitivity.statusDone': 'Completado', + 'sensitivity.statusSkipped': 'Omitido', + 'sensitivity.scanStatus': 'Estado', + + 'sensitivityList.subtitle': 'Escanear y clasificar los niveles de sensibilidad de campos en sus fuentes de datos', + 'sensitivityList.noDatasources': 'No hay fuentes de datos disponibles', + 'sensitivityList.scanned': 'Escaneado', + 'sensitivityList.unscanned': 'Sin escanear', + + 'my.sensitivity.title': 'Configuración de niveles de sensibilidad', + 'my.sensitivity.desc': 'Configure las definiciones de niveles de sensibilidad y el rango de acceso del agente IA.', + 'my.sensitivity.accessSensitivity': 'Sensibilidad de acceso de AI Agent', + 'my.sensitivity.noRestriction': 'Sin restricción', + 'my.sensitivity.accessFrom': 'Desde', + 'my.sensitivity.accessTo': 'Hasta', + 'my.sensitivity.examples': 'Agregar ejemplo...', + 'my.sensitivity.editExamples': 'Editar ejemplos', + 'my.sensitivity.editExamplesPrompt': 'Editar ejemplos (separados por comas):', + 'my.sensitivity.pickColor': 'Elegir color', + 'my.sensitivity.examplesHint': 'Estos ejemplos de nombres de campo se proporcionan como referencia cuando la IA clasifica su esquema.', + 'my.sensitivity.levelKey': 'Clave', + 'my.sensitivity.levelName': 'Nombre del nivel', + 'my.sensitivity.levelDesc': 'Descripción del nivel', + 'my.sensitivity.addLevel': 'Agregar nivel', + 'my.sensitivity.save': 'Guardar', + 'my.sensitivity.resetDefaults': 'Restablecer valores predeterminados', + 'my.sensitivity.saved': 'Configuración de sensibilidad guardada.', + 'my.sensitivity.resetSuccess': 'Niveles de sensibilidad restablecidos.', + 'my.sensitivityScan.title': 'Escaneo de sensibilidad', + 'my.sensitivityScan.desc': 'Seleccione múltiples fuentes de datos y ejecute la clasificación de sensibilidad por lotes.', + 'my.sensitivityScan.aiConfig': 'Configuración de IA', + 'my.sensitivityScan.noDatasources': 'No se encontraron fuentes de datos. Agregue una primero.', + 'my.sensitivityScan.selectAll': 'Seleccionar todo', + 'my.sensitivityScan.startScan': 'Iniciar escaneo', + 'my.sensitivityScan.scanning': 'Escaneando...', + 'my.sensitivityScan.stop': 'Detener', + 'my.sensitivityScan.queued': 'En cola', + 'my.sensitivityScan.completed': 'Completado', + 'my.sensitivityScan.failed': 'Fallido', + 'sensitivity.levelDef.L1.name': 'Público', + 'sensitivity.levelDef.L1.desc': 'Datos operativos no sensibles', + 'sensitivity.levelDef.L2.name': 'Interno', + 'sensitivity.levelDef.L2.desc': 'Identificadores internos y metadatos', + 'sensitivity.levelDef.L3.name': 'Confidencial', + 'sensitivity.levelDef.L3.desc': 'PII indirecto, datos de comportamiento y ubicación', + 'sensitivity.levelDef.L4.name': 'Sensible', + 'sensitivity.levelDef.L4.desc': 'PII directo, datos financieros y médicos', + 'sensitivity.levelDef.L5.name': 'Crítico', + 'sensitivity.levelDef.L5.desc': 'Credenciales, instrumentos de pago y datos personales altamente sensibles', + + 'route.sensitivity': 'Sensibilidad de campos', + }, + de: { + 'common.color.green': 'Grün', + 'common.color.blue': 'Blau', + 'common.color.yellow': 'Gelb', + 'common.color.orange': 'Orange', + 'common.color.red': 'Rot', + 'common.color.purple': 'Lila', + 'common.color.pink': 'Rosa', + 'common.color.gray': 'Grau', + 'nav.sources': 'Datenquellen', + 'nav.history': 'Verlauf', + 'nav.dataSensitivity': 'Datensensitivität', + 'nav.riskRules': 'Risikoregeln', + 'nav.aiSettings': 'KI-Einstellungen', + 'nav.my': 'Mein Bereich', + + 'route.datasources': 'Datenquellen', + 'route.datasourceCreate': 'Neue Datenquelle', + 'route.datasourceEdit': 'Datenquelle bearbeiten', + 'route.console': 'Konsole', + 'route.history': 'Verlauf', + 'route.sensitivityList': 'Datensensitivität', + 'route.aiSettings': 'KI-Einstellungen', + 'route.aiSettingsCreate': 'Neue KI-Konfiguration', + 'route.aiSettingsEdit': 'KI-Konfiguration bearbeiten', + 'route.my': 'Mein Bereich', + 'route.default': 'Dashboard', + + 'my.menu.label': 'Mein Menü', + 'my.menu.knowledgeBase': 'Wissensbasis', + 'my.menu.language': 'Sprache', + 'my.menu.chooseHint': 'Wähle einen Menüpunkt, um fortzufahren.', + + 'my.language.title': 'Sprache', + 'my.language.desc': 'Wähle deine bevorzugte UI-Sprache. Das gilt auch für Explain-Texte und Kontextmenüs.', + 'my.language.current': 'Aktuelle Sprache', + 'my.language.option.en': 'English', + 'my.language.option.zh': '中文', + 'my.language.option.ja': '日本語', + 'my.language.option.es': 'Español', + 'my.language.option.de': 'Deutsch', + 'my.language.selectLabel': 'Sprache wählen', + 'my.language.saved': 'Sprache aktualisiert.', + + 'datasource.list.subtitle': 'Verwalte deine Datenquellen.', + + 'history.subtitle': 'Prüfe den Ausführungsverlauf.', + 'history.searchPlaceholder': 'Nach Statement, Datenquelle oder Tabellenname suchen', + 'history.clearFiltered': 'Gefilterte Ergebnisse löschen', + + 'visualization.subtitle': 'Erwecke deine Daten zum Leben.', + + 'ai.panel.subtitle': 'Passe KI-Konfigurationen an.', + 'ai.panel.providers': 'Liste der KI-Konfigurationen', + 'ai.panel.addProvider': 'Konfiguration hinzufügen', + 'ai.panel.empty': 'Noch keine KI-Konfigurationen vorhanden. Klicke auf „Konfiguration hinzufügen“, um zu starten.', + 'ai.panel.noConnected': 'Keine verbundenen KI-Konfigurationen.', + 'ai.panel.noFailed': 'Keine fehlgeschlagenen KI-Konfigurationen.', + 'ai.panel.deleteTitle': 'KI-Konfiguration löschen', + 'ai.panel.deleted': 'KI-Konfiguration gelöscht', + + 'ai.form.titleEdit': 'KI-Konfiguration bearbeiten', + 'ai.form.titleCreate': 'Neue KI-Konfiguration', + + 'ai.sidebar.analyzeRiskNote': + 'Dabei wird eine Stichprobe der Ergebniszeilen an deinen konfigurierten KI-Anbieter zur Datenanalyse gesendet. Nur genehmigen, wenn du diese Daten teilen darfst.', + 'ai.sidebar.visualizationRiskNote': + 'Dabei wird eine Stichprobe der Ergebniszeilen an deinen konfigurierten KI-Anbieter gesendet, um Datenvisualisierungen zu erzeugen. Nur genehmigen, wenn du diese Daten teilen darfst.', + 'ai.sidebar.noProvider': 'Keine verfügbaren KI-Konfigurationen.', + 'ai.sidebar.providerFallback': 'KI-Konfiguration', + + 'kb.subtitle': + 'Lade datenbezogene Dokumente hoch (Schema-Notizen, Felderklärungen), damit die KI deine Daten besser versteht.', + 'kb.providerNotReady': 'Keine verfügbare KI-Konfiguration. Konfiguriere zuerst die KI-Einstellungen, um automatische Zusammenfassungen nach dem Upload zu aktivieren.', + + 'sensitivity.title': 'Feldsensitivität', + 'sensitivity.infoBanner': 'Klassifizieren Sie Ihre Datenbankfelder nach Sensitivitätsstufe. Nach der Klassifizierung können sensible Felder bei der Datenübertragung über MCP/Skill-Integrationen maskiert oder gehasht werden, um versehentliche Offenlegung zu verhindern.', + 'sensitivity.selectProvider': 'KI-Anbieter auswählen', + 'sensitivity.customRules': 'Benutzerdefinierte Klassifizierungsregeln', + 'sensitivity.customRulesHint': 'Beschreiben Sie, welche Felder sensible Daten enthalten. Diese Regeln werden gespeichert und für alle Datenquellen wiederverwendet.', + 'sensitivity.customRulesPlaceholder': 'Z.B.: Felder mit "phone", "mobile" sind PII-Kontaktdaten. Felder die auf "_id" enden sind Kennungen. Die Spalten "salary" und "bonus" sind Finanzdaten.', + 'sensitivity.scan': 'Scannen', + 'sensitivity.scanning': 'Scanne...', + 'sensitivity.scanComplete': 'Scan abgeschlossen', + 'sensitivity.scanFailed': 'Scan fehlgeschlagen: {message}', + 'sensitivity.customRulesLoadFailed': 'Benutzerdefinierte Regeln konnten nicht geladen werden. Bitte erneut versuchen.', + 'sensitivity.noReport': 'Noch kein Bericht vorhanden. Wähle einen KI-Anbieter und führe einen Scan durch.', + 'sensitivity.noSchema': 'Kein Schema-Cache verfügbar — öffne zuerst die Konsole dieser Datenquelle.', + 'sensitivity.allEntitiesSkipped': 'Alle Entitäten wurden übersprungen — keine Felddetails verfügbar. Öffne und beschreibe zuerst die Tabellen in der Konsole.', + 'sensitivity.autoDescribing': 'Tabellenschemata werden vor dem Scan automatisch abgerufen. Bitte einen Moment warten.', + 'sensitivity.lastScanned': 'Letzter Scan: {time}', + 'sensitivity.entity': 'Entität', + 'sensitivity.field': 'Feld', + 'sensitivity.level': 'Sensitivitätsstufe', + 'sensitivity.category': 'Kategorie', + 'sensitivity.reason': 'Begründung', + 'sensitivity.source': 'Quelle', + 'sensitivity.confirm': 'Bestätigen', + 'sensitivity.override': 'Überschreiben', + 'sensitivity.level.critical': 'Kritisch', + 'sensitivity.level.high': 'Hoch', + 'sensitivity.level.medium': 'Mittel', + 'sensitivity.level.low': 'Niedrig', + 'sensitivity.level.unconfirmed': 'Unbestätigt', + 'sensitivity.category.pii': 'Personenbezogene Daten', + 'sensitivity.category.credential': 'Zugangsdaten', + 'sensitivity.category.financial': 'Finanzen', + 'sensitivity.category.behavioral': 'Verhalten', + 'sensitivity.category.medical': 'Medizinisch', + 'sensitivity.category.location': 'Standort', + 'sensitivity.category.contact': 'Kontakt', + 'sensitivity.category.identifier': 'Kennung', + 'sensitivity.category.none': 'Keine', + 'sensitivity.source.ai': 'KI', + 'sensitivity.source.agent': 'Agent', + 'sensitivity.source.manual': 'Manuell', + 'sensitivity.mode': 'Modus', + 'sensitivity.mode.whitelist': 'Whitelist', + 'sensitivity.mode.blacklist': 'Blacklist', + 'sensitivity.mode.whitelistDesc': 'Alle Felder gelten standardmäßig als sensibel, es sei denn, sie sind als niedrig eingestuft.', + 'sensitivity.mode.blacklistDesc': 'Alle Felder gelten standardmäßig als nicht sensibel, es sei denn, sie sind als mittel oder höher eingestuft.', + 'sensitivity.progress': '{scanned}/{total} Entitäten', + 'sensitivity.fieldCount': '{count} Felder', + 'sensitivity.deleteConfirm': 'Alle Klassifizierungsergebnisse dieser Datenquelle löschen?', + 'sensitivity.deleted': 'Klassifizierungsergebnisse gelöscht.', + 'sensitivity.statusPending': 'Ausstehend', + 'sensitivity.statusScanning': 'Wird gescannt', + 'sensitivity.statusDone': 'Abgeschlossen', + 'sensitivity.statusSkipped': 'Übersprungen', + 'sensitivity.scanStatus': 'Status', + + 'sensitivityList.subtitle': 'Feldsensitivitätsstufen Ihrer Datenquellen scannen und klassifizieren', + 'sensitivityList.noDatasources': 'Keine Datenquellen verfügbar', + 'sensitivityList.scanned': 'Gescannt', + 'sensitivityList.unscanned': 'Nicht gescannt', + + 'my.sensitivity.title': 'Sensibilitätsstufen-Konfiguration', + 'my.sensitivity.desc': 'Konfigurieren Sie Stufendefinitionen und den KI-Agenten-Zugriffsbereich.', + 'my.sensitivity.accessSensitivity': 'AI Agent Zugriffssensibilität', + 'my.sensitivity.noRestriction': 'Keine Einschränkung', + 'my.sensitivity.accessFrom': 'Von', + 'my.sensitivity.accessTo': 'Bis', + 'my.sensitivity.examples': 'Beispiel hinzufügen...', + 'my.sensitivity.editExamples': 'Beispiele bearbeiten', + 'my.sensitivity.editExamplesPrompt': 'Beispiele bearbeiten (kommagetrennt):', + 'my.sensitivity.pickColor': 'Farbe wählen', + 'my.sensitivity.examplesHint': 'Diese Feldnamen-Beispiele dienen der KI als Referenz bei der Schema-Klassifizierung.', + 'my.sensitivity.levelKey': 'Schlüssel', + 'my.sensitivity.levelName': 'Stufenname', + 'my.sensitivity.levelDesc': 'Stufenbeschreibung', + 'my.sensitivity.addLevel': 'Stufe hinzufügen', + 'my.sensitivity.save': 'Speichern', + 'my.sensitivity.resetDefaults': 'Auf Standard zurücksetzen', + 'my.sensitivity.saved': 'Sensibilitätskonfiguration gespeichert.', + 'my.sensitivity.resetSuccess': 'Sensibilitätsstufen auf Standard zurückgesetzt.', + 'my.sensitivityScan.title': 'Sensibilitätsscan', + 'my.sensitivityScan.desc': 'Wählen Sie mehrere Datenquellen aus und führen Sie die Sensibilitätsklassifizierung im Batch aus.', + 'my.sensitivityScan.aiConfig': 'KI-Konfiguration', + 'my.sensitivityScan.noDatasources': 'Keine Datenquellen gefunden. Fügen Sie zuerst eine hinzu.', + 'my.sensitivityScan.selectAll': 'Alle auswählen', + 'my.sensitivityScan.startScan': 'Scan starten', + 'my.sensitivityScan.scanning': 'Wird gescannt...', + 'my.sensitivityScan.stop': 'Stoppen', + 'my.sensitivityScan.queued': 'Warteschlange', + 'my.sensitivityScan.completed': 'Abgeschlossen', + 'my.sensitivityScan.failed': 'Fehlgeschlagen', + 'sensitivity.levelDef.L1.name': 'Öffentlich', + 'sensitivity.levelDef.L1.desc': 'Nicht sensible Betriebsdaten', + 'sensitivity.levelDef.L2.name': 'Intern', + 'sensitivity.levelDef.L2.desc': 'Interne Kennungen und Metadaten', + 'sensitivity.levelDef.L3.name': 'Vertraulich', + 'sensitivity.levelDef.L3.desc': 'Indirekte PII, Verhaltens- und Standortdaten', + 'sensitivity.levelDef.L4.name': 'Sensibel', + 'sensitivity.levelDef.L4.desc': 'Direkte PII, Finanz- und Gesundheitsdaten', + 'sensitivity.levelDef.L5.name': 'Kritisch', + 'sensitivity.levelDef.L5.desc': 'Anmeldedaten, Zahlungsinstrumente und hochsensible personenbezogene Daten', + + 'route.sensitivity': 'Feldsensitivität', + }, +} + +const locale = ref('en') +let initialized = false + +type LocalStorageLike = { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +const resolveLocale = (value: string | null | undefined): AppLocale | null => { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return null + if (normalized.startsWith('zh')) return 'zh' + if (normalized.startsWith('ja')) return 'ja' + if (normalized.startsWith('es')) return 'es' + if (normalized.startsWith('de')) return 'de' + if (normalized.startsWith('en')) return 'en' + return null +} + +const getLanguageCandidates = (): string[] => { + const values: string[] = [] + if (typeof document !== 'undefined') values.push(document.documentElement.lang) + if (typeof navigator !== 'undefined') { + if (Array.isArray(navigator.languages)) values.push(...navigator.languages) + values.push(navigator.language) + } + return values.filter((item) => String(item || '').trim()) +} + +const applyDocumentLang = (value: AppLocale) => { + if (typeof document !== 'undefined') { + document.documentElement.lang = value + } +} + +const getStorage = (): LocalStorageLike | null => { + try { + const candidate = (globalThis as { localStorage?: unknown }).localStorage as LocalStorageLike | undefined + if (!candidate) return null + if (typeof candidate.getItem !== 'function') return null + if (typeof candidate.setItem !== 'function') return null + return candidate + } catch { + return null + } +} + +const readStoredLocale = (): AppLocale | null => { + const storage = getStorage() + if (!storage) return null + try { + return resolveLocale(storage.getItem(LOCALE_STORAGE_KEY)) + } catch { + return null + } +} + +const persistLocale = (value: AppLocale) => { + const storage = getStorage() + if (!storage) return + try { + storage.setItem(LOCALE_STORAGE_KEY, value) + } catch { + // Ignore persistence errors (e.g. blocked Web Storage) and keep in-memory locale. + } +} + +export const initAppI18n = () => { + if (initialized) return + + const stored = readStoredLocale() + if (stored) { + locale.value = stored + } else { + let detected: AppLocale | null = null + for (const candidate of getLanguageCandidates()) { + const resolved = resolveLocale(candidate) + if (resolved) { + detected = resolved + break + } + } + locale.value = detected || 'en' + persistLocale(locale.value) + } + + applyDocumentLang(locale.value) + initialized = true +} + +export const appLocaleRef = readonly(locale) + +export const getAppLocale = (): AppLocale => { + if (!initialized) initAppI18n() + return locale.value +} + +export const setAppLocale = (value: AppLocale | string) => { + const resolved = resolveLocale(String(value || '')) || 'en' + locale.value = resolved + persistLocale(resolved) + applyDocumentLang(resolved) + initialized = true +} + +export const tApp = (key: string, params: MessageParams = {}): string => { + const current = getAppLocale() + const template = APP_MESSAGES[current][key] ?? APP_MESSAGES.en[key] ?? key + return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? `{${name}}`)) +} + +export const formatAppList = ( + values: Array, + separatorKey: 'common.listSeparator' | 'common.metricSeparator' = 'common.listSeparator', +): string => { + const items = values.map((value) => String(value).trim()).filter(Boolean) + if (!items.length) return '' + return items.join(tApp(separatorKey)) +} + +/** Look up a key in the English message table (for comparing against stored values). */ +export const tAppEn = (key: string): string => { + return APP_MESSAGES.en[key] ?? key +} + +export const resetAppI18nForTest = () => { + initialized = false + locale.value = 'en' +} diff --git a/frontend/src/modules/logging/clientErrors.ts b/frontend/src/modules/logging/clientErrors.ts new file mode 100644 index 0000000..7ce99b4 --- /dev/null +++ b/frontend/src/modules/logging/clientErrors.ts @@ -0,0 +1,34 @@ +export type ClientErrorReporter = (kind: string, message: string, detail: string) => Promise + +const stringifyDetail = (value: unknown) => { + if (value instanceof Error) return value.stack || value.message + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +export const installClientErrorLogging = (report: ClientErrorReporter) => { + const onError = (event: ErrorEvent) => { + void report('error', String(event.message || 'Unknown error'), stringifyDetail(event.error || event.message)).catch((err) => { + console.error('report client error failed', err) + }) + } + + const onUnhandledRejection = (event: PromiseRejectionEvent) => { + void report('unhandledrejection', 'Unhandled promise rejection', stringifyDetail(event.reason)).catch((err) => { + console.error('report unhandled rejection failed', err) + }) + } + + window.addEventListener('error', onError) + window.addEventListener('unhandledrejection', onUnhandledRejection) + + return () => { + window.removeEventListener('error', onError) + window.removeEventListener('unhandledrejection', onUnhandledRejection) + } +} diff --git a/frontend/src/modules/mongo/core/createIndex.ts b/frontend/src/modules/mongo/core/createIndex.ts new file mode 100644 index 0000000..c80b209 --- /dev/null +++ b/frontend/src/modules/mongo/core/createIndex.ts @@ -0,0 +1,123 @@ +import { readStringLiteral, splitMongoArgsWithPositions } from '../json' +import { formatMongoFieldKey, isValidMongoIdent } from './ident' +import { extractMongoCollectionName, mongoCollectionRef } from './refs' +import { findMatchingParen } from './parser' + +export function findMongoCreateIndexContext(rawValue: string) { + if (!rawValue) return null + const trimmedRight = rawValue.replace(/\s+$/, '') + if (/\.createIndex\s*\(\s*$/i.test(trimmedRight)) return buildMongoCreateIndexContext(rawValue, true) + if (/\.createIndex\s*$/i.test(trimmedRight)) return buildMongoCreateIndexContext(rawValue, false) + return null +} + +export function findMongoCreateIndexKeyContext(statement: string, caretPos: number) { + if (!statement || caretPos === null || caretPos === undefined) return null + const lower = statement.toLowerCase() + const methodIdx = lower.lastIndexOf('.createindex', caretPos) + if (methodIdx === -1) return null + const openParen = statement.indexOf('(', methodIdx) + if (openParen === -1 || caretPos < openParen) return null + const closeParen = findMatchingParen(statement, openParen) + const end = closeParen === -1 ? statement.length : closeParen + if (caretPos > end) return null + const argsText = statement.slice(openParen + 1, end) + const args = splitMongoArgsWithPositions(argsText, openParen + 1) + let firstArg = args[0] + if (!firstArg) firstArg = { text: '', start: openParen + 1, end: openParen + 1 } + if (caretPos < firstArg.start || caretPos > firstArg.end) return null + const collectionName = extractMongoCollectionName(statement.slice(0, methodIdx)) + return { firstArg, collectionName, base: statement.slice(0, openParen), withParen: true } +} + +function buildMongoCreateIndexContext(rawValue: string, withParen: boolean) { + const lower = rawValue.toLowerCase() + const idx = lower.lastIndexOf('.createindex') + const prefix = idx !== -1 ? rawValue.slice(0, idx) : rawValue + const collectionName = extractMongoCollectionName(prefix) + const usesGetCollection = /getCollection\s*\(/i.test(prefix) || /db\s*\[/.test(prefix) + const needsBaseFix = collectionName && !usesGetCollection && !isValidMongoIdent(collectionName) + const basePrefix = needsBaseFix && collectionName ? `${mongoCollectionRef(collectionName)}.createIndex` : rawValue + return { base: basePrefix, withParen, collectionName } +} + +export function applyCreateIndexFieldSuggestion(statement: string, context: any, field: string) { + if (!statement || !context || !field) return { value: statement } + const argStart = context.firstArg.start + const argEnd = context.firstArg.end + const argText = statement.slice(argStart, argEnd) + const trimmed = argText.trim() + const key = formatMongoFieldKey(field) + + if (!trimmed || !trimmed.includes('{') || !trimmed.includes('}')) { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + + const openBrace = argText.indexOf('{') + const closeBrace = argText.lastIndexOf('}') + if (openBrace === -1 || closeBrace === -1 || closeBrace < openBrace) { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + + const bodyText = argText.slice(openBrace + 1, closeBrace) + const hasPlaceholder = /(^|[,{]\s*)("field"|field)\s*:/i.test(bodyText) + + if (!bodyText.trim()) { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + + if (hasPlaceholder) { + let i = openBrace + 1 + while (i < closeBrace && /\s/.test(argText[i])) i += 1 + if (i >= closeBrace) { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + + const keyStart = i + let keyEnd = i + if (argText[i] === '"' || argText[i] === "'") { + try { + const parsed = readStringLiteral(argText, i) + keyEnd = parsed.next + } catch { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + } else { + while (keyEnd < closeBrace && /[A-Za-z0-9_$]/.test(argText[keyEnd])) keyEnd += 1 + } + + const colonPos = argText.indexOf(':', keyEnd) + if (colonPos === -1) { + const replacement = `{${key}: 1}` + return { value: statement.slice(0, argStart) + replacement + statement.slice(argEnd), caret: argStart + replacement.length - 1 } + } + + const newArgText = argText.slice(0, keyStart) + key + argText.slice(keyEnd) + return { value: statement.slice(0, argStart) + newArgText + statement.slice(argEnd), caret: argStart + keyStart + key.length } + } + + const trimmedBody = bodyText.trim() + const needsComma = trimmedBody && !trimmedBody.endsWith(',') + const insertion = `${needsComma ? ', ' : ' '}${key}: 1` + const newArgText = argText.slice(0, closeBrace) + insertion + argText.slice(closeBrace) + const newValue = statement.slice(0, argStart) + newArgText + statement.slice(argEnd) + return { value: newValue, caret: argStart + closeBrace + insertion.length - 1 } +} + +export function extractMongoIndexFields(indexes: any[]) { + const set = new Set() + const list = Array.isArray(indexes) ? indexes : [] + list.forEach((idx) => { + if (!idx) return + const cols = String(idx.column || '').split(',').map((s: string) => s.trim()).filter(Boolean) + if (!cols.length) return + if (String(idx.name || '') === '_id_' && cols.length === 1 && cols[0] === '_id') return + cols.forEach((c) => { if (c && c !== '_id') set.add(c) }) + }) + return Array.from(set) +} diff --git a/frontend/src/modules/mongo/core/ident.ts b/frontend/src/modules/mongo/core/ident.ts new file mode 100644 index 0000000..b2679d0 --- /dev/null +++ b/frontend/src/modules/mongo/core/ident.ts @@ -0,0 +1,8 @@ +export function isValidMongoIdent(name: string) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name || '') +} + +export function formatMongoFieldKey(name: string) { + if (isValidMongoIdent(name)) return name + return `\"${name}\"` +} diff --git a/frontend/src/modules/mongo/core/index.ts b/frontend/src/modules/mongo/core/index.ts new file mode 100644 index 0000000..756f839 --- /dev/null +++ b/frontend/src/modules/mongo/core/index.ts @@ -0,0 +1,16 @@ +export { mongoCollectionMethods, mongoDbMethods, buildMongoStatement } from './methods' +export { isValidMongoIdent, formatMongoFieldKey } from './ident' +export { mongoCollectionRef, extractMongoCollectionName } from './refs' +export { parseMongoInput, findMatchingParen } from './parser' +export { + findMongoCreateIndexContext, + findMongoCreateIndexKeyContext, + applyCreateIndexFieldSuggestion, + extractMongoIndexFields, +} from './createIndex' +export { + shortenMongoSummaryText, + formatMongoSummaryValue, + extractMongoEqualityFilterPairs, + shouldRefreshMongoEntities, +} from './summary' diff --git a/frontend/src/modules/mongo/core/methods.ts b/frontend/src/modules/mongo/core/methods.ts new file mode 100644 index 0000000..6f1bdf1 --- /dev/null +++ b/frontend/src/modules/mongo/core/methods.ts @@ -0,0 +1,20 @@ +import { mongoCollectionRef } from './refs' + +export const mongoCollectionMethods = [ + { label: 'find', snippet: 'find({})' }, + { label: 'insertOne', snippet: 'insertOne({})' }, + { label: 'insertMany', snippet: 'insertMany([{}])' }, + { label: 'updateOne', snippet: 'updateOne({}, {$set: {}})' }, + { label: 'updateMany', snippet: 'updateMany({}, {$set: {}})' }, + { label: 'deleteOne', snippet: 'deleteOne({})' }, + { label: 'deleteMany', snippet: 'deleteMany({})' }, + { label: 'aggregate', snippet: 'aggregate([{}])' }, + { label: 'createIndex', snippet: 'createIndex({field: 1}, {unique: true})' }, + { label: 'dropIndex', snippet: 'dropIndex(\"index_name\")' }, +] + +export const mongoDbMethods = [{ label: 'createCollection', snippet: 'createCollection(\"collection\")' }] + +export function buildMongoStatement(collection: string, snippet: string) { + return `${mongoCollectionRef(collection)}.${snippet}` +} diff --git a/frontend/src/modules/mongo/core/parser.ts b/frontend/src/modules/mongo/core/parser.ts new file mode 100644 index 0000000..675afdd --- /dev/null +++ b/frontend/src/modules/mongo/core/parser.ts @@ -0,0 +1,40 @@ +export function findMatchingParen(statement: string, openIndex: number) { + let depth = 0 + let quote: string | null = null + let escaped = false + for (let i = openIndex; i < statement.length; i += 1) { + const ch = statement[i] + if (quote) { + if (escaped) { escaped = false; continue } + if (ch === '\\') { escaped = true; continue } + if (ch === quote) quote = null + continue + } + if (ch === '"' || ch === "'") { quote = ch; continue } + if (ch === '(') depth += 1 + else if (ch === ')') { + depth -= 1 + if (depth === 0) return i + } + } + return -1 +} + +export function parseMongoInput(raw: string) { + const trimmed = (raw || '').trim().replace(/;$/, '') + if (!trimmed.startsWith('db.')) return null + + const withoutDb = trimmed.slice(3) + const openParen = trimmed.indexOf('(') + const head = openParen === -1 ? withoutDb : trimmed.slice(3, openParen) + const firstCloseParen = openParen === -1 ? -1 : findMatchingParen(trimmed, openParen) + const argsText = openParen === -1 ? '' : trimmed.slice(openParen + 1, firstCloseParen > openParen ? firstCloseParen : undefined) + const chainSuffix = firstCloseParen > -1 && firstCloseParen + 1 < trimmed.length ? trimmed.slice(firstCloseParen + 1) : '' + + if (!head.includes('.')) { + return { collection: '', methodPrefix: head, hasParen: openParen !== -1, raw: trimmed, dbMethod: true, argsText, chainSuffix } + } + + const [collection, methodPrefix] = head.split('.') + return { collection, methodPrefix: methodPrefix || '', hasParen: openParen !== -1, raw: trimmed, dbMethod: false, argsText, chainSuffix } +} diff --git a/frontend/src/modules/mongo/core/refs.ts b/frontend/src/modules/mongo/core/refs.ts new file mode 100644 index 0000000..16cfcaf --- /dev/null +++ b/frontend/src/modules/mongo/core/refs.ts @@ -0,0 +1,19 @@ +import { isValidMongoIdent } from './ident' + +export function mongoCollectionRef(name: string) { + if (!name) return 'db.collection' + if (isValidMongoIdent(name)) return `db.${name}` + return `db.getCollection(\"${name}\")` +} + +export function extractMongoCollectionName(statement: string) { + const getCollectionMatch = statement.match(/db\s*\.?\s*getCollection\s*\(\s*(['"])(.*?)\1\s*\)/i) + if (getCollectionMatch) return getCollectionMatch[2] + const bracketMatch = statement.match(/db\s*\[\s*(['"])(.*?)\1\s*\]/i) + if (bracketMatch) return bracketMatch[2] + const dotMatch = statement.match(/db\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/) + if (dotMatch) return dotMatch[1] + const looseMatch = statement.match(/db\s*\.\s*([^\.\s\(]+)/) + if (looseMatch) return looseMatch[1] + return '' +} diff --git a/frontend/src/modules/mongo/core/summary.ts b/frontend/src/modules/mongo/core/summary.ts new file mode 100644 index 0000000..ce1ac59 --- /dev/null +++ b/frontend/src/modules/mongo/core/summary.ts @@ -0,0 +1,49 @@ +import { normalizeMongoJSON, splitMongoArgs } from '../json' +import { parseMongoInput } from './parser' + +export function shortenMongoSummaryText(text: any, maxLen: number) { + const s = String(text === undefined || text === null ? '' : text) + const limit = Number(maxLen) || 80 + if (!s) return '' + if (s.length <= limit) return s + return s.slice(0, Math.max(0, limit - 3)) + '...' +} + +export function formatMongoSummaryValue(val: any) { + if (val === null) return 'null' + if (val === undefined) return '' + if (typeof val === 'object') { + if (val && val.$oid) return shortenMongoSummaryText(String(val.$oid), 80) + if (val && val.$date) return shortenMongoSummaryText(String(val.$date), 80) + try { return shortenMongoSummaryText(JSON.stringify(val), 80) } catch { return shortenMongoSummaryText(String(val), 80) } + } + return shortenMongoSummaryText(String(val), 80) +} + +export function extractMongoEqualityFilterPairs(statement: string) { + const parsed = parseMongoInput((statement || '').trim()) + if (!parsed || parsed.dbMethod) return [] + + const method = String(parsed.methodPrefix || '').toLowerCase() + if (!['find', 'updateone', 'updatemany', 'deleteone', 'deletemany', 'findoneandupdate'].includes(method)) return [] + + const args = splitMongoArgs(parsed.argsText || '') + const rawFilter = (args[0] || '').trim() + if (!rawFilter || !rawFilter.startsWith('{')) return [] + + try { + const normalized = normalizeMongoJSON(rawFilter) + const obj = JSON.parse(normalized) + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return [] + return Object.entries(obj).filter(([key, val]) => key && val !== undefined && typeof val !== 'object').map(([key, val]) => ({ key, val })) + } catch { + return [] + } +} + +export function shouldRefreshMongoEntities(statement: string) { + const parsed = parseMongoInput(statement) + if (!parsed || parsed.dbMethod) return false + const method = String(parsed.methodPrefix || '').toLowerCase() + return method === 'createcollection' || method === 'dropcollection' || method === 'renamecollection' +} diff --git a/frontend/src/modules/mongo/datasource.ts b/frontend/src/modules/mongo/datasource.ts new file mode 100644 index 0000000..c066e35 --- /dev/null +++ b/frontend/src/modules/mongo/datasource.ts @@ -0,0 +1,129 @@ +export function deriveMongoDisplay(ds: any) { + const databaseLabel = ds?.database || mongoDatabaseFromOptions(ds?.options) || '-' + const hostLabel = mongoHostLabelFromOptions(ds?.host, ds?.port, ds?.options) || '-' + return { hostLabel, databaseLabel } +} + +export function mongoHostLabelFromOptions(host?: string, port?: number, options?: Record) { + if (host && port) { + return `${host}:${port}` + } + if (options?.hosts && Array.isArray(options.hosts) && options.hosts.length) { + return options.hosts.join(',') + } + if (options?.uri) { + const parsed = parseMongoURI(String(options.uri)) + if (parsed.hosts) { + return parsed.hosts + } + } + return '' +} + +export function mongoDatabaseFromOptions(options?: Record) { + if (!options?.uri) { + return '' + } + const parsed = parseMongoURI(String(options.uri)) + return parsed.db || '' +} + +export function mongoDatabaseFromDatasource(ds: any) { + return ds?.database || mongoDatabaseFromOptions(ds?.options) || '' +} + +export function parseMongoURI(uri: string) { + const input = (uri || '').trim() + const schemeIdx = input.indexOf('://') + if (schemeIdx === -1) { + return { hosts: '', db: '' } + } + let rest = input.slice(schemeIdx + 3) + const at = rest.lastIndexOf('@') + if (at !== -1) { + rest = rest.slice(at + 1) + } + const slash = rest.indexOf('/') + if (slash === -1) { + return { hosts: rest, db: '' } + } + const hosts = rest.slice(0, slash) + let path = rest.slice(slash + 1) + const q = path.indexOf('?') + if (q !== -1) { + path = path.slice(0, q) + } + const db = path.split('/')[0] || '' + return { hosts, db } +} + +export function inferMongoConnMode(ds: any) { + if (!ds || ds.type !== 'mongodb') { + return 'userpass' + } + if (ds?.options?.uri) { + return 'uri' + } + return 'userpass' +} + +export function applyMongoFormOptions(base: Record | undefined, form: { + mode: string + uri: string + tls: boolean + sslEnabled: boolean + sslrootcert: string + replicaSet: string + hosts: string +}) { + const options = base && typeof base === 'object' ? { ...base } : {} + options.sslEnabled = Boolean(form.sslEnabled) + const certificate = form.sslrootcert.trim() + if (form.mode === 'uri') { + const uri = form.uri.trim() + if (uri) { + options.uri = uri + } else { + delete options.uri + } + delete options.tls + if (options.sslEnabled && certificate) { + options.sslrootcert = certificate + } else { + delete options.sslrootcert + } + delete options.hosts + delete options.replicaSet + return options + } + delete options.uri + if (form.sslEnabled || form.tls) { + options.tls = true + } else { + delete options.tls + } + if (options.sslEnabled && certificate) { + options.sslrootcert = certificate + } else { + delete options.sslrootcert + } + const replicaSet = form.replicaSet.trim() + if (replicaSet) { + options.replicaSet = replicaSet + } else { + delete options.replicaSet + } + const hostsRaw = form.hosts.trim() + const hosts = hostsRaw + ? hostsRaw + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : [] + if (hosts.length) { + options.hosts = hosts + } else { + delete options.hosts + } + return options +} diff --git a/frontend/src/modules/mongo/json.ts b/frontend/src/modules/mongo/json.ts new file mode 100644 index 0000000..1dc48b9 --- /dev/null +++ b/frontend/src/modules/mongo/json.ts @@ -0,0 +1,218 @@ +export function splitMongoArgs(raw: string): string[] { + const args: string[] = [] + let depth = 0 + let quote: string | null = null + let escaped = false + let start = 0 + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i] + if (quote) { + if (escaped) { + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === quote) { + quote = null + } + continue + } + if (ch === '"' || ch === "'") { + quote = ch + continue + } + if (ch === '{' || ch === '[' || ch === '(') { + depth += 1 + continue + } + if (ch === '}' || ch === ']' || ch === ')') { + if (depth > 0) { + depth -= 1 + } + continue + } + if (ch === ',' && depth === 0) { + args.push(raw.slice(start, i)) + start = i + 1 + } + } + if (start <= raw.length) { + args.push(raw.slice(start)) + } + return args +} + +export interface MongoArgWithPos { + text: string + start: number + end: number +} + +export function splitMongoArgsWithPositions(raw: string, offset: number): MongoArgWithPos[] { + const args: MongoArgWithPos[] = [] + let depth = 0 + let quote: string | null = null + let escaped = false + let start = 0 + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i] + if (quote) { + if (escaped) { + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === quote) { + quote = null + } + continue + } + if (ch === '"' || ch === "'") { + quote = ch + continue + } + if (ch === '{' || ch === '[' || ch === '(') { + depth += 1 + continue + } + if (ch === '}' || ch === ']' || ch === ')') { + if (depth > 0) { + depth -= 1 + } + continue + } + if (ch === ',' && depth === 0) { + args.push({ + text: raw.slice(start, i), + start: offset + start, + end: offset + i, + }) + start = i + 1 + } + } + if (start <= raw.length) { + args.push({ + text: raw.slice(start), + start: offset + start, + end: offset + raw.length, + }) + } + return args +} + +export function readStringLiteral(input: string, start: number): { value: string; next: number } { + const quote = input[start] + let value = '' + for (let i = start + 1; i < input.length; i += 1) { + const ch = input[i] + if (ch === '\\') { + const next = input[i + 1] + if (next === 'n') value += '\n' + else if (next === 'r') value += '\r' + else if (next === 't') value += '\t' + else if (next === 'b') value += '\b' + else if (next === 'f') value += '\f' + else if (next === 'u') { + const code = input.slice(i + 2, i + 6) + value += String.fromCharCode(parseInt(code, 16)) + i += 4 + } else { + value += next + } + i += 1 + continue + } + if (ch === quote) { + return { value, next: i + 1 } + } + value += ch + } + throw new Error('unterminated string') +} + +export function normalizeMongoJSONWithMap(input: string): { json: string; map: number[] } { + let out = '' + const map: number[] = [] + const stack: string[] = [] + let expectingKey = false + let i = 0 + while (i < input.length) { + const ch = input[i] + if (ch === '"' || ch === "'") { + const start = i + const parsed = readStringLiteral(input, i) + const serialized = JSON.stringify(parsed.value) + out += serialized + for (let k = 0; k < serialized.length; k += 1) { + map.push(start) + } + i = parsed.next + continue + } + if (expectingKey && /[A-Za-z_$]/.test(ch)) { + let j = i + 1 + while (j < input.length && /[A-Za-z0-9_$]/.test(input[j])) { + j += 1 + } + const key = input.slice(i, j) + const serialized = JSON.stringify(key) + out += serialized + for (let k = 0; k < serialized.length; k += 1) { + map.push(i) + } + i = j + continue + } + if (ch === '{') { + stack.push('{') + expectingKey = true + } else if (ch === '[') { + stack.push('[') + expectingKey = false + } else if (ch === '}' || ch === ']') { + stack.pop() + expectingKey = false + } else if (ch === ':') { + expectingKey = false + } else if (ch === ',') { + expectingKey = stack[stack.length - 1] === '{' + } + out += ch + map.push(i) + i += 1 + } + return { json: out, map } +} + +export function normalizeMongoJSON(input: string): string { + return normalizeMongoJSONWithMap(input).json +} + +export function hasMongoHelperCall(input: string): boolean { + return ( + /(ObjectId|ISODate|UUID|NumberLong|NumberInt|Timestamp|BinData)\s*\(/.test(input) || + /\bnew\s+Date\b/.test(input) || + /\bDate\s*\(/.test(input) + ) +} + +export function isLikelyMongoJsonArg(arg: string): boolean { + const trimmed = arg.trim() + if (!trimmed) { + return false + } + if (hasMongoHelperCall(trimmed)) { + return false + } + const first = trimmed[0] + if (first === '{' || first === '[' || first === '"' || first === "'" || /[0-9-]/.test(first)) { + return true + } + return false +} diff --git a/frontend/src/modules/mongo/lint-internal.ts b/frontend/src/modules/mongo/lint-internal.ts new file mode 100644 index 0000000..788836b --- /dev/null +++ b/frontend/src/modules/mongo/lint-internal.ts @@ -0,0 +1,92 @@ +export function findLastMongoCallArgs(statement: string) { + const raw = (statement || '').trim() + if (!raw) { + return null + } + let quote: string | null = null + let escaped = false + let depth = 0 + let close = -1 + for (let i = raw.length - 1; i >= 0; i -= 1) { + const ch = raw[i] + if (quote) { + if (escaped) { + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === quote) { + quote = null + } + continue + } + if (ch === '"' || ch === "'") { + quote = ch + continue + } + if (ch === ')') { + if (close === -1) { + close = i + } + depth += 1 + } else if (ch === '(') { + depth -= 1 + if (depth === 0) { + return { open: i, close: close === -1 ? raw.length : close } + } + } + } + return null +} + +export function detectMissingColonInObjectLiteral(input: string) { + if (!input.startsWith('{')) { + return null + } + let quote: string | null = null + let escaped = false + let depth = 0 + let keyStart = -1 + for (let i = 0; i < input.length; i += 1) { + const ch = input[i] + if (quote) { + if (escaped) { + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === quote) { + quote = null + } + continue + } + if (ch === '"' || ch === "'") { + quote = ch + if (depth === 1 && keyStart === -1) { + keyStart = i + } + continue + } + if (ch === '{') { + depth += 1 + continue + } + if (ch === '}') { + depth -= 1 + continue + } + if (depth === 1 && keyStart !== -1 && ch === ',') { + return { index: keyStart, message: 'Missing ":" in Mongo object.' } + } + if (depth === 1 && keyStart !== -1 && ch === ':') { + keyStart = -1 + } + } + return null +} diff --git a/frontend/src/modules/mongo/lint.ts b/frontend/src/modules/mongo/lint.ts new file mode 100644 index 0000000..a0dd4bc --- /dev/null +++ b/frontend/src/modules/mongo/lint.ts @@ -0,0 +1,219 @@ +import { + isLikelyMongoJsonArg, + normalizeMongoJSONWithMap, + splitMongoArgsWithPositions, +} from './json' +import { isValidMongoIdent } from './core' +import { detectMissingColonInObjectLiteral, findLastMongoCallArgs } from './lint-internal' + +export interface MongoLintResult { + start?: number + end?: number + message: string +} + +export function mongoLineColumn(text: string, index: number) { + let line = 1 + let column = 1 + const limit = Math.min(index, text.length) + for (let i = 0; i < limit; i += 1) { + if (text[i] === '\n') { + line += 1 + column = 1 + } else { + column += 1 + } + } + return { line, column } +} + +export function describeMongoLint(lint: MongoLintResult | null, statement: string) { + if (!lint) { + return '' + } + let message = lint.message || 'Invalid Mongo statement.' + if (typeof lint.start === 'number') { + const loc = mongoLineColumn(statement, lint.start) + const hint = mongoCharHint(statement, lint.start) + if (hint && !message.includes('Near')) { + message = `${message} Near ${hint}.` + } + return `${message} (Line ${loc.line}, Col ${loc.column})` + } + return message +} + +export function mongoCharHint(statement: string, index: number) { + if (typeof index !== 'number' || index < 0 || index >= statement.length) { + return '' + } + const ch = statement[index] + if (ch === '\n') return 'newline' + if (ch === '\t') return 'tab' + if (ch === ' ') return 'space' + if (ch === '"') return '"' + if (ch === "'") return "'" + if (ch === '\\') return '\\\\' + if (ch < ' ') return 'control char' + return `"${ch}"` +} + +export function findMongoLint(statement: string) { + const balance = findMongoBalanceLint(statement) + if (balance) { + return balance + } + const collectionLint = findMongoCollectionLint(statement) + if (collectionLint) { + return collectionLint + } + const jsonLint = findMongoJsonLint(statement) + if (jsonLint) { + return jsonLint + } + return null +} + +function findMongoBalanceLint(statement: string): MongoLintResult | null { + let quote: string | null = null + let quoteStart = -1 + let escaped = false + const stack: Array<{ ch: string; index: number }> = [] + for (let i = 0; i < statement.length; i += 1) { + const ch = statement[i] + if (quote) { + if (escaped) { + escaped = false + continue + } + if (ch === '\\') { + escaped = true + continue + } + if (ch === quote) { + quote = null + quoteStart = -1 + } + continue + } + if (ch === '"' || ch === "'") { + quote = ch + quoteStart = i + continue + } + if (ch === '(' || ch === '[' || ch === '{') { + stack.push({ ch, index: i }) + continue + } + if (ch === ')' || ch === ']' || ch === '}') { + if (!stack.length) { + return { start: i, end: i + 1, message: `Unexpected "${ch}".` } + } + const last = stack.pop() + if ( + (last?.ch === '(' && ch !== ')') || + (last?.ch === '[' && ch !== ']') || + (last?.ch === '{' && ch !== '}') + ) { + return { start: i, end: i + 1, message: `Mismatched "${ch}".` } + } + } + } + if (quote) { + return { start: quoteStart, end: statement.length, message: 'Unterminated string.' } + } + if (stack.length) { + const last = stack[stack.length - 1] + const expected = last.ch === '(' ? ')' : last.ch === '[' ? ']' : '}' + return { start: last.index, end: last.index + 1, message: `Missing "${expected}".` } + } + return null +} + +function findMongoCollectionLint(statement: string): MongoLintResult | null { + const raw = statement || '' + if (!raw.includes('db')) { + return null + } + if (/db\s*\.\s*getCollection\s*\(/.test(raw) || /db\s*\[/.test(raw)) { + return null + } + const dbIndex = raw.indexOf('db') + if (dbIndex === -1) { + return null + } + let i = dbIndex + 2 + while (i < raw.length && /\s/.test(raw[i])) i += 1 + if (i >= raw.length || raw[i] !== '.') { + return null + } + i += 1 + while (i < raw.length && /\s/.test(raw[i])) i += 1 + const start = i + while (i < raw.length && !/[\s\.\(]/.test(raw[i])) i += 1 + const name = raw.slice(start, i) + if (!name) { + return null + } + if (i >= raw.length || raw[i] !== '.') { + return null + } + if (isValidMongoIdent(name)) { + return null + } + return { + start, + end: i, + message: `Collection "${name}" contains invalid characters. Use db.getCollection("${name}") instead.`, + } +} + +function findMongoJsonLint(statement: string): MongoLintResult | null { + const argsRange = findLastMongoCallArgs(statement) + if (!argsRange) { + return null + } + const { open, close } = argsRange + const argsText = statement.slice(open + 1, close) + if (!argsText.trim()) { + return null + } + const args = splitMongoArgsWithPositions(argsText, open + 1) + for (const arg of args) { + const trimmed = arg.text.trim() + if (!trimmed) { + continue + } + if (!isLikelyMongoJsonArg(trimmed)) { + continue + } + const leading = arg.text.length - arg.text.trimStart().length + const trailing = arg.text.length - arg.text.trimEnd().length + const argStart = arg.start + leading + const argEnd = arg.end - trailing + const colonIssue = detectMissingColonInObjectLiteral(trimmed) + if (colonIssue) { + const start = argStart + colonIssue.index + return { start, end: start + 1, message: colonIssue.message } + } + try { + const normalized = normalizeMongoJSONWithMap(trimmed) + JSON.parse(normalized.json) + } catch (err) { + const message = (err as Error)?.message || 'Invalid Mongo JSON.' + const normalized = normalizeMongoJSONWithMap(trimmed) + const posMatch = message.match(/position\s+(\d+)/i) + if (posMatch && normalized.map.length) { + const pos = Number(posMatch[1]) + const mapped = normalized.map[Math.min(pos, normalized.map.length - 1)] + if (typeof mapped === 'number') { + const base = arg.start + leading + const offset = base + mapped + return { start: offset, end: offset + 1, message: 'Invalid Mongo JSON near this position.' } + } + } + return { start: argStart, end: argEnd, message: 'Invalid Mongo JSON. Example: {"xx": "vvv"}.' } + } + } + return null +} diff --git a/frontend/src/modules/plan/limits.ts b/frontend/src/modules/plan/limits.ts new file mode 100644 index 0000000..ded7f86 --- /dev/null +++ b/frontend/src/modules/plan/limits.ts @@ -0,0 +1,217 @@ +import { tApp } from '@/modules/i18n/appI18n' +import type { AuthLicense, AuthTrial } from '@/types' + +export const PLAN_LIMIT_ERROR_CODES = { + datasourceCreate: 'plan_limit_datasource_create', + customRiskRules: 'plan_limit_custom_risk_rules', +} as const + +const PLAN_LIMIT_ERROR_PREFIX = 'plan_limit_exceeded:' + +type PlanLimitFeature = 'datasources' | 'risk_rules' | 'devices' +type KnownPlan = 'free' | 'pro' + +export type EffectiveStatus = 'active' | 'free' | 'pro_expired' | 'trial' + +export interface EffectiveEntitlement { + rawPlan: KnownPlan | null + rawStatus: string + expiresAt: number + trialExpiresAt: number + effectivePlan: KnownPlan + effectiveStatus: EffectiveStatus +} + +const PRO_EXPIRED_RAW_STATUS = new Set(['expired', 'pro_expired']) + +// evaluateLicense converts a stored AuthLicense plus the current time into an +// EffectiveEntitlement. Mirrors internal/planlimits.EvaluateLicense so the UI +// and backend gates agree on the same effective plan/status. +export const evaluateLicense = ( + license: AuthLicense | null | undefined, + nowMs: number = Date.now(), + trial?: AuthTrial | null, +): EffectiveEntitlement => { + const rawPlanInput = String(license?.plan ?? '').trim().toLowerCase() + const rawPlan: KnownPlan | null = rawPlanInput === 'pro' || rawPlanInput === 'free' ? rawPlanInput : null + const rawStatus = String(license?.status ?? '').trim().toLowerCase() + const expiresAt = Number(license?.expiresAt ?? 0) || 0 + + const ent: EffectiveEntitlement = { + rawPlan, + rawStatus, + expiresAt, + trialExpiresAt: Number(trial?.expiresAt ?? 0) || 0, + effectivePlan: 'free', + effectiveStatus: 'free', + } + + if (rawPlanInput !== 'pro') { + if (isTrialActive(trial, nowMs)) { + ent.effectivePlan = 'pro' + ent.effectiveStatus = 'trial' + } + return ent + } + + const nowSeconds = Math.floor(nowMs / 1000) + const expiredByStatus = PRO_EXPIRED_RAW_STATUS.has(rawStatus) + const expiredByDate = expiresAt > 0 && expiresAt <= nowSeconds + + if (expiredByStatus || expiredByDate) { + if (isTrialActive(trial, nowMs)) { + ent.effectivePlan = 'pro' + ent.effectiveStatus = 'trial' + return ent + } + ent.effectivePlan = 'free' + ent.effectiveStatus = 'pro_expired' + return ent + } + ent.effectivePlan = 'pro' + ent.effectiveStatus = 'active' + return ent +} + +export const effectivePlanFor = ( + license: AuthLicense | null | undefined, + nowMs?: number, + trial?: AuthTrial | null, +): KnownPlan => evaluateLicense(license, nowMs, trial).effectivePlan + +export const isTrialActive = (trial: AuthTrial | null | undefined, nowMs: number = Date.now()): boolean => { + const expiresAt = Number(trial?.expiresAt ?? 0) || 0 + if (expiresAt <= 0) return false + return expiresAt > Math.floor(nowMs / 1000) +} + +type ParsedPlanLimitError = { + feature: PlanLimitFeature + plan: KnownPlan + limit: number +} + +export const normalizePlan = (value: unknown): KnownPlan | null => { + if (value === null || value === undefined) { + return null + } + const plan = String(value).trim().toLowerCase() + if (plan === 'free' || plan === 'pro') { + return plan + } + return 'free' +} + +export const planLabel = (value: unknown) => { + const plan = normalizePlan(value) + return plan ? tApp(`plan.name.${plan}`) : '' +} + +export const datasourceLimitForPlan = (value: unknown): number | null => { + return normalizePlan(value) === 'free' ? 3 : null +} + +export const deviceLimitForPlan = (value: unknown): number | null => { + const plan = normalizePlan(value) + if (plan === 'pro') return 3 + if (plan === 'free') return 1 + return null +} + +export const canManagePolicyRules = (value: unknown, options: { isAuthenticated?: boolean } = {}) => { + const plan = normalizePlan(value) + if (options.isAuthenticated === false) return plan === 'pro' + if (options.isAuthenticated === true) return true + return plan === null || plan === 'pro' +} + +export const canManageCustomRiskRules = canManagePolicyRules + +export const canManageBuiltinRiskRules = (value: unknown, options: { isAuthenticated?: boolean } = {}) => { + const plan = normalizePlan(value) + if (options.isAuthenticated === false) return plan === 'pro' + return plan === null || plan === 'pro' +} + +export const hasReachedDatasourceLimit = (value: unknown, currentCount: number) => { + const limit = datasourceLimitForPlan(value) + return limit !== null && currentCount >= limit +} + +export const datasourceLimitNotice = (value: unknown) => { + const plan = normalizePlan(value) + if (!plan) return '' + return tApp('plan.notice.datasourceLimit', { + plan: tApp(`plan.name.${plan}`), + limit: datasourceLimitForPlan(plan) ?? 0, + }) +} + +export const customRiskRulesNotice = (value: unknown, options: { isAuthenticated?: boolean } = {}) => { + const plan = normalizePlan(value) + if (options.isAuthenticated === false) return plan === 'pro' ? '' : tApp('auth.notice.signInForRiskRules') + if (options.isAuthenticated === true) return '' + if (!plan) return '' + return tApp('plan.notice.riskRules', { + plan: tApp(`plan.name.${plan}`), + }) +} + +export const builtinRiskRulesNotice = (value: unknown, options: { isAuthenticated?: boolean } = {}) => { + const plan = normalizePlan(value) + if (options.isAuthenticated === false) return plan === 'pro' ? '' : tApp('auth.notice.signInForRiskRules') + if (!plan) return '' + return tApp('plan.notice.riskRules', { + plan: tApp(`plan.name.${plan}`), + }) +} + +export const deviceLimitNotice = (value: unknown, limit?: number) => { + const plan = normalizePlan(value) + if (!plan) return '' + return tApp('plan.notice.deviceLimit', { + plan: tApp(`plan.name.${plan}`), + limit: limit ?? deviceLimitForPlan(plan) ?? 0, + }) +} + +const parsePlanLimitError = (err: unknown): ParsedPlanLimitError | null => { + const message = err instanceof Error ? err.message : String(err || '') + if (!message.startsWith(PLAN_LIMIT_ERROR_PREFIX)) return null + const [, featureRaw = '', planRaw = '', limitRaw = '0'] = message.split(':') + if (featureRaw !== 'datasources' && featureRaw !== 'risk_rules' && featureRaw !== 'devices') { + return null + } + const plan = normalizePlan(planRaw) + if (!plan) { + return null + } + const parsedLimit = Number.parseInt(limitRaw, 10) + return { + feature: featureRaw, + plan, + limit: Number.isFinite(parsedLimit) && parsedLimit >= 0 ? parsedLimit : 0, + } +} + +export const resolvePlanLimitMessage = (err: unknown, value: unknown) => { + const parsed = parsePlanLimitError(err) + if (parsed?.feature === 'datasources') { + return datasourceLimitNotice(parsed.plan) + } + if (parsed?.feature === 'risk_rules') { + return customRiskRulesNotice(parsed.plan) + } + if (parsed?.feature === 'devices') { + return deviceLimitNotice(parsed.plan, parsed.limit) + } + + const message = err instanceof Error ? err.message : String(err || '') + if (message === PLAN_LIMIT_ERROR_CODES.datasourceCreate) { + return datasourceLimitNotice(value) + } + if (message === PLAN_LIMIT_ERROR_CODES.customRiskRules) { + return customRiskRulesNotice(value) + } + return '' +} diff --git a/frontend/src/modules/redis/command-args.test.ts b/frontend/src/modules/redis/command-args.test.ts new file mode 100644 index 0000000..e350e7c --- /dev/null +++ b/frontend/src/modules/redis/command-args.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { parseRedisCommandArgs } from './command-args' + +describe('parseRedisCommandArgs', () => { + it('preserves quoted Redis arguments and binary escapes', () => { + expect(parseRedisCommandArgs(String.raw`SET "fd quoted key" "\x00A\nB"`)).toEqual([ + 'SET', + 'fd quoted key', + '\x00A\nB', + ]) + }) + + it('preserves empty quoted arguments and escaped apostrophes', () => { + expect(parseRedisCommandArgs(String.raw`SET 'user profile' 'Bob\'s value' ""`)).toEqual([ + 'SET', + 'user profile', + "Bob's value", + '', + ]) + }) + + it('keeps backslashes literal inside single quoted arguments', () => { + expect(parseRedisCommandArgs(String.raw`SET path 'C:\\tmp'`)).toEqual([ + 'SET', + 'path', + 'C:\\\\tmp', + ]) + }) + + it('rejects unterminated quoted arguments', () => { + expect(() => parseRedisCommandArgs(String.raw`SET "unterminated`)).toThrow(/unterminated/i) + }) + + it('rejects non-whitespace after a closing quote', () => { + expect(() => parseRedisCommandArgs(String.raw`SET "value"suffix`)).toThrow(/whitespace/i) + }) +}) diff --git a/frontend/src/modules/redis/command-args.ts b/frontend/src/modules/redis/command-args.ts new file mode 100644 index 0000000..33ad11b --- /dev/null +++ b/frontend/src/modules/redis/command-args.ts @@ -0,0 +1,122 @@ +export function parseRedisCommandArgs(statement: string): string[] { + const args: string[] = [] + let index = 0 + + while (true) { + while (index < statement.length && isRedisSpace(statement.charCodeAt(index))) { + index += 1 + } + if (index >= statement.length) break + + let arg = '' + while (index < statement.length && !isRedisSpace(statement.charCodeAt(index))) { + const ch = statement[index] + if (ch === '"') { + const parsed = parseDoubleQuoted(statement, index + 1) + arg += parsed.value + index = parsed.index + } else if (ch === "'") { + const parsed = parseSingleQuoted(statement, index + 1) + arg += parsed.value + index = parsed.index + } else { + arg += ch + index += 1 + } + } + args.push(arg) + } + + if (args.length === 0) { + throw new Error('statement required') + } + return args +} + +const parseDoubleQuoted = (statement: string, start: number) => { + let value = '' + let index = start + while (index < statement.length) { + const ch = statement[index] + if (ch === '"') { + index += 1 + if (index < statement.length && !isRedisSpace(statement.charCodeAt(index))) { + throw new Error('closing double quote must be followed by whitespace') + } + return { value, index } + } + if (ch === '\\' && index + 1 < statement.length) { + const next = statement[index + 1] + if ( + next === 'x' + && index + 3 < statement.length + && isRedisHex(statement.charCodeAt(index + 2)) + && isRedisHex(statement.charCodeAt(index + 3)) + ) { + value += String.fromCharCode((fromRedisHex(statement.charCodeAt(index + 2)) << 4) | fromRedisHex(statement.charCodeAt(index + 3))) + index += 4 + continue + } + index += 2 + switch (next) { + case 'n': + value += '\n' + break + case 'r': + value += '\r' + break + case 't': + value += '\t' + break + case 'b': + value += '\b' + break + case 'a': + value += '\x07' + break + default: + value += next + break + } + continue + } + value += ch + index += 1 + } + throw new Error('unterminated double quote') +} + +const parseSingleQuoted = (statement: string, start: number) => { + let value = '' + let index = start + while (index < statement.length) { + const ch = statement[index] + if (ch === "'") { + index += 1 + if (index < statement.length && !isRedisSpace(statement.charCodeAt(index))) { + throw new Error('closing single quote must be followed by whitespace') + } + return { value, index } + } + if (ch === '\\' && statement[index + 1] === "'") { + value += "'" + index += 2 + continue + } + value += ch + index += 1 + } + throw new Error('unterminated single quote') +} + +const isRedisSpace = (code: number) => + code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09 || code === 0x0b || code === 0x0c + +const isRedisHex = (code: number) => + (0x30 <= code && code <= 0x39) || (0x61 <= code && code <= 0x66) || (0x41 <= code && code <= 0x46) + +const fromRedisHex = (code: number) => { + if (0x30 <= code && code <= 0x39) return code - 0x30 + if (0x61 <= code && code <= 0x66) return code - 0x61 + 10 + return code - 0x41 + 10 +} diff --git a/frontend/src/modules/redis/command-docs.ts b/frontend/src/modules/redis/command-docs.ts new file mode 100644 index 0000000..eca0b23 --- /dev/null +++ b/frontend/src/modules/redis/command-docs.ts @@ -0,0 +1,272 @@ +import type { RedisCommandDocsResponse } from '@/types' +import { api } from '@/services/api' + +import defaultDocsJson from './commands.json' + +export type RedisCommandArgument = { + name?: string + type?: string + display_text?: string + token?: string + optional?: boolean + multiple?: boolean + arguments?: RedisCommandArgument[] +} + +export type RedisCommandDoc = { + arguments?: RedisCommandArgument[] + summary?: string +} + +export type RedisCommandDocs = { + updatedAt: number + commands: Record +} + +export type RedisCommandSuggestion = { + command: string + syntax: string + summary: string +} + +type StorageLike = Pick + +const STORAGE_KEY = 'redis-command-docs:v1' +const memoryStore = new Map() + +const fallbackStorage: StorageLike = { + getItem: (key) => (memoryStore.has(key) ? memoryStore.get(key)! : null), + setItem: (key, value) => { + memoryStore.set(key, value) + }, + removeItem: (key) => { + memoryStore.delete(key) + }, +} + +const isStorageLike = (value: unknown): value is StorageLike => { + if (!value || typeof value !== 'object') return false + const candidate = value as StorageLike + return ( + typeof candidate.getItem === 'function' && + typeof candidate.setItem === 'function' && + typeof candidate.removeItem === 'function' + ) +} + +const storage: StorageLike = + typeof localStorage === 'undefined' || !isStorageLike(localStorage) + ? fallbackStorage + : localStorage + +const normalizeDocs = (input: unknown): RedisCommandDocs => { + if (!input || typeof input !== 'object') { + return { updatedAt: 0, commands: {} } + } + const raw = input as Record + const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0 + const commands = isCommandMap(raw.commands) + ? (raw.commands as Record) + : isCommandMap(raw) + ? (raw as Record) + : {} + return { updatedAt, commands: normalizeCommandMap(commands) } +} + +const isCommandMap = (value: unknown): value is Record => { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + +const normalizeCommandMap = (commands: Record): Record => { + const normalized: Record = {} + for (const [key, value] of Object.entries(commands)) { + if (!key) continue + normalized[key.toUpperCase()] = value + } + return normalized +} + +const defaultDocs = normalizeDocs(defaultDocsJson) + +const readCache = (): RedisCommandDocs | null => { + try { + const raw = storage.getItem(STORAGE_KEY) + if (!raw) return null + return normalizeDocs(JSON.parse(raw)) + } catch { + return null + } +} + +const writeCache = (docs: RedisCommandDocs) => { + try { + storage.setItem(STORAGE_KEY, JSON.stringify(docs)) + } catch { + // Ignore cache write errors. + } +} + +export const clearRedisCommandDocsCache = () => { + storage.removeItem(STORAGE_KEY) +} + +export const loadRedisCommandDocs = (): RedisCommandDocs => { + const cached = readCache() + if (cached && cached.updatedAt >= defaultDocs.updatedAt && Object.keys(cached.commands).length > 0) { + return cached + } + return defaultDocs +} + +export const refreshRedisCommandDocs = async ( + datasourceId: string, + fetcher: (id: string) => Promise = api.getRedisCommandDocs, +): Promise => { + const current = loadRedisCommandDocs() + if (!datasourceId) return current + try { + const response = await fetcher(datasourceId) + const next = normalizeDocs(response) + if (Object.keys(next.commands).length === 0 || next.updatedAt <= current.updatedAt) { + return current + } + writeCache(next) + return next + } catch { + return current + } +} + +export const formatRedisCommandSyntax = ( + input: string, + docs: RedisCommandDocs = loadRedisCommandDocs(), +): string | null => { + const commandName = resolveCommandName(input, docs.commands) + if (!commandName) return null + const command = docs.commands[commandName] + if (!command) return null + const args = Array.isArray(command.arguments) ? command.arguments : [] + const renderedArgs = args.map(formatArgument).filter(Boolean).join(' ') + return renderedArgs ? `${commandName} ${renderedArgs}` : commandName +} + +export const getRedisCommandCompletion = ( + input: string, + docs: RedisCommandDocs = loadRedisCommandDocs(), +): string => { + const commandName = resolveCommandName(input, docs.commands) + if (!commandName) return '' + const command = docs.commands[commandName] + if (!command) return '' + const args = Array.isArray(command.arguments) ? command.arguments : [] + const argTokens = args.map(formatArgument).filter(Boolean) + if (!argTokens.length) return '' + + const raw = String(input ?? '') + const normalizedInput = raw.replace(/\s+/g, ' ').trim() + if (!normalizedInput) return '' + const inputTokens = normalizedInput.split(' ') + const commandTokens = commandName.split(' ') + if (inputTokens.length < commandTokens.length) return '' + + const requiredTokens = args + .filter((arg) => arg && typeof arg === 'object' && !arg.optional) + .map(formatArgument) + .filter(Boolean) + const requiredCount = requiredTokens.length + const typedArgCount = Math.max(0, inputTokens.length - commandTokens.length) + const dropCount = Math.min(typedArgCount, requiredCount) + const remaining = argTokens.slice(dropCount) + if (!remaining.length) return '' + return ` ${remaining.join(' ')}` +} + +export const getRedisInlineHint = ( + input: string, + caretStart: number, + caretEnd: number, + docs: RedisCommandDocs = loadRedisCommandDocs(), +): { prefix: string; suffix: string } | null => { + if (caretStart !== caretEnd) return null + if (caretEnd !== input.length) return null + const suffix = getRedisCommandCompletion(input, docs) + if (!suffix) return null + return { prefix: input, suffix } +} + +export const getRedisCommandSuggestions = ( + input: string, + docs: RedisCommandDocs = loadRedisCommandDocs(), + limit = 8, +): RedisCommandSuggestion[] => { + const raw = String(input ?? '') + const trimmed = raw.trim() + if (!trimmed || /\s/.test(raw)) return [] + const prefix = trimmed.toUpperCase() + const commands = normalizeCommandMap(docs.commands || {}) + return Object.keys(commands) + .filter((command) => command.startsWith(prefix)) + .sort((a, b) => { + if (a === prefix) return -1 + if (b === prefix) return 1 + return a.localeCompare(b) + }) + .slice(0, Math.max(1, limit)) + .map((command) => ({ + command, + syntax: formatRedisCommandSyntax(command, { ...docs, commands }) || command, + summary: String(commands[command]?.summary || ''), + })) +} + +const resolveCommandName = (input: string, commands: Record): string => { + const trimmed = String(input ?? '').trim() + if (!trimmed) return '' + const normalized = trimmed.toUpperCase().replace(/\s+/g, ' ') + if (commands[normalized]) return normalized + const parts = normalized.split(' ') + for (let i = parts.length; i > 0; i -= 1) { + const candidate = parts.slice(0, i).join(' ') + if (commands[candidate]) return candidate + } + return parts[0] ?? '' +} + +const formatArgument = (arg: RedisCommandArgument): string => { + if (!arg || typeof arg !== 'object') return '' + + let content = '' + const children = Array.isArray(arg.arguments) ? arg.arguments.map(formatArgument).filter(Boolean) : [] + + if (children.length > 0) { + if (arg.type === 'oneof') { + content = children.join('|') + } else { + content = children.join(' ') + } + if (arg.token) { + content = `${arg.token} ${content}`.trim() + } + } else { + content = formatToken(arg) + } + + if (!content) return '' + if (arg.multiple) { + content = `${content}...` + } + if (arg.optional) { + return `[${content}]` + } + return content +} + +const formatToken = (arg: RedisCommandArgument): string => { + if (arg.token) { + if (arg.type !== 'pure-token' && arg.display_text) { + return `${arg.token} ${arg.display_text}`.trim() + } + return arg.token + } + return arg.display_text || arg.name || '' +} diff --git a/frontend/src/modules/redis/command-safety.test.ts b/frontend/src/modules/redis/command-safety.test.ts new file mode 100644 index 0000000..eb071ff --- /dev/null +++ b/frontend/src/modules/redis/command-safety.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { getRedisCommandRisk } from './command-safety' + +describe('getRedisCommandRisk', () => { + it('flags KEYS commands', () => { + expect(getRedisCommandRisk('KEYS *')?.id).toBe('keys') + expect(getRedisCommandRisk('KEYS user:*')?.id).toBe('keys') + }) + + it('flags large scans', () => { + expect(getRedisCommandRisk('SCAN 0')?.id).toBe('scan') + expect(getRedisCommandRisk('SCAN 0 MATCH *')?.id).toBe('scan') + expect(getRedisCommandRisk('SCAN 0 COUNT 1000')?.id).toBe('scan') + expect(getRedisCommandRisk('SSCAN key 0')?.id).toBe('scan') + }) + + it('allows scoped scans', () => { + expect(getRedisCommandRisk('SCAN 0 MATCH user:* COUNT 100')).toBeNull() + expect(getRedisCommandRisk('SSCAN key 0 MATCH field:* COUNT 200')).toBeNull() + }) + + it('flags flush and monitor commands', () => { + expect(getRedisCommandRisk('FLUSHALL')?.id).toBe('flush') + expect(getRedisCommandRisk('FLUSHDB')?.id).toBe('flush') + expect(getRedisCommandRisk('MONITOR')?.id).toBe('monitor') + }) + + it('flags dangerous admin commands', () => { + expect(getRedisCommandRisk('CLIENT PAUSE 1000')?.id).toBe('client_pause') + expect(getRedisCommandRisk('SCRIPT KILL')?.id).toBe('script_kill') + expect(getRedisCommandRisk('CONFIG SET requirepass foo')?.id).toBe('config_set') + expect(getRedisCommandRisk('SHUTDOWN')?.id).toBe('shutdown') + }) +}) diff --git a/frontend/src/modules/redis/command-safety.ts b/frontend/src/modules/redis/command-safety.ts new file mode 100644 index 0000000..f0c8541 --- /dev/null +++ b/frontend/src/modules/redis/command-safety.ts @@ -0,0 +1,173 @@ +export type RedisCommandRisk = { + id: string + label: string + detail: string + command: string +} + +const scanCommands = new Set(['SCAN', 'SSCAN', 'HSCAN', 'ZSCAN']) +const scanCountThreshold = 1000 + +const tokenizeRedisCommand = (input: string): string[] => { + const tokens: string[] = [] + let current = '' + let quote: '"' | "'" | null = null + let escaping = false + for (const char of input) { + if (escaping) { + current += char + escaping = false + continue + } + if (char === '\\') { + escaping = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } else { + current += char + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (/\s/.test(char)) { + if (current) { + tokens.push(current) + current = '' + } + continue + } + current += char + } + if (current) tokens.push(current) + return tokens +} + +const buildRisk = (id: string, label: string, detail: string, command: string): RedisCommandRisk => ({ + id, + label, + detail, + command, +}) + +const parseScanOptions = (tokens: string[], startIndex: number) => { + let matchPattern: string | null = null + let count: number | null = null + let index = startIndex + while (index < tokens.length) { + const token = tokens[index].toUpperCase() + if (token === 'MATCH' && index + 1 < tokens.length) { + matchPattern = tokens[index + 1] + index += 2 + continue + } + if (token === 'COUNT' && index + 1 < tokens.length) { + const raw = Number.parseInt(tokens[index + 1], 10) + if (!Number.isNaN(raw)) count = raw + index += 2 + continue + } + index += 1 + } + return { matchPattern, count } +} + +const buildScanRisk = (command: string, reason: string, input: string) => + buildRisk( + 'scan', + `Large ${command} detected`, + `${command} without a narrow MATCH or with a high COUNT can block Redis (${reason}).`, + input, + ) + +export const getRedisCommandRisk = (input: string): RedisCommandRisk | null => { + const trimmed = input.trim() + if (!trimmed) return null + const tokens = tokenizeRedisCommand(trimmed) + if (!tokens.length) return null + + const primary = tokens[0].toUpperCase() + const secondary = tokens[1]?.toUpperCase() || '' + + if (primary === 'KEYS') { + return buildRisk( + 'keys', + 'KEYS command', + 'KEYS scans the entire keyspace and can block the Redis server.', + trimmed, + ) + } + + if (scanCommands.has(primary)) { + const optionStart = primary === 'SCAN' ? 2 : 3 + const { matchPattern, count } = parseScanOptions(tokens, optionStart) + const reasons: string[] = [] + if (!matchPattern) reasons.push('missing MATCH') + if (matchPattern === '*') reasons.push('MATCH *') + if (count !== null && count >= scanCountThreshold) reasons.push(`COUNT ${count}`) + if (reasons.length) { + return buildScanRisk(primary, reasons.join(', '), trimmed) + } + } + + if (primary === 'FLUSHALL' || primary === 'FLUSHDB') { + return buildRisk( + 'flush', + primary, + `${primary} removes keys and blocks the server during execution.`, + trimmed, + ) + } + + if (primary === 'MONITOR') { + return buildRisk( + 'monitor', + 'MONITOR command', + 'MONITOR can degrade Redis performance by streaming all commands.', + trimmed, + ) + } + + if (primary === 'CLIENT' && secondary === 'PAUSE') { + return buildRisk( + 'client_pause', + 'CLIENT PAUSE', + 'CLIENT PAUSE stops processing commands for a period of time.', + trimmed, + ) + } + + if (primary === 'SCRIPT' && secondary === 'KILL') { + return buildRisk( + 'script_kill', + 'SCRIPT KILL', + 'SCRIPT KILL interrupts running scripts and can impact clients.', + trimmed, + ) + } + + if (primary === 'CONFIG' && secondary === 'SET') { + return buildRisk( + 'config_set', + 'CONFIG SET', + 'CONFIG SET changes server configuration and can impact availability.', + trimmed, + ) + } + + if (primary === 'SHUTDOWN') { + return buildRisk( + 'shutdown', + 'SHUTDOWN command', + 'SHUTDOWN stops the Redis server.', + trimmed, + ) + } + + return null +} diff --git a/frontend/src/modules/redis/commands.json b/frontend/src/modules/redis/commands.json new file mode 100644 index 0000000..221486d --- /dev/null +++ b/frontend/src/modules/redis/commands.json @@ -0,0 +1,24926 @@ +{ + "updatedAt": 1768469168, + "commands": { + "ACL": { + "summary": "A container for Access List Control commands.", + "since": "6.0.0", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "ACL CAT": { + "summary": "Lists the ACL categories, or the commands inside a category.", + "since": "6.0.0", + "group": "server", + "complexity": "O(1) since the categories and commands are a fixed set.", + "acl_categories": [ + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "category", + "type": "string", + "display_text": "category", + "optional": true + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "ACL DELUSER": { + "summary": "Deletes ACL users, and terminates their connections.", + "since": "6.0.0", + "group": "server", + "complexity": "O(1) amortized time considering the typical user.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username", + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "ACL DRYRUN": { + "summary": "Simulates the execution of a command by a user, without executing the command.", + "since": "7.0.0", + "group": "server", + "complexity": "O(1).", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username" + }, + { + "name": "command", + "type": "string", + "display_text": "command" + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL GENPASS": { + "summary": "Generates a pseudorandom, secure password that can be used to identify ACL users.", + "since": "6.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "bits", + "type": "integer", + "display_text": "bits", + "optional": true + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "ACL GETUSER": { + "summary": "Lists the ACL rules of a user.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of password, command and pattern rules that the user has.", + "history": [ + [ + "6.2.0", + "Added Pub/Sub channel patterns." + ], + [ + "7.0.0", + "Added selectors and changed the format of key and channel patterns from a list to their rule representation." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username" + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "6.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "ACL LIST": { + "summary": "Dumps the effective rules in ACL file format.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of configured users.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL LOAD": { + "summary": "Reloads the rules from the configured ACL file.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of configured users.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL LOG": { + "summary": "Lists recent security events generated due to ACL rules.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N) with N being the number of entries shown.", + "history": [ + [ + "7.2.0", + "Added entry ID, timestamp created, and timestamp last updated." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "operation", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count" + }, + { + "name": "reset", + "type": "pure-token", + "display_text": "reset", + "token": "RESET" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL SAVE": { + "summary": "Saves the effective ACL rules in the configured ACL file.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of configured users.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "ACL SETUSER": { + "summary": "Creates and modifies an ACL user and its rules.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of rules provided.", + "history": [ + [ + "6.2.0", + "Added Pub/Sub channel patterns." + ], + [ + "7.0.0", + "Added selectors and key based permissions." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username" + }, + { + "name": "rule", + "type": "string", + "display_text": "rule", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "ACL USERS": { + "summary": "Lists all ACL users.", + "since": "6.0.0", + "group": "server", + "complexity": "O(N). Where N is the number of configured users.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "ACL WHOAMI": { + "summary": "Returns the authenticated username of the current connection.", + "since": "6.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "AI.MODELDEL": { + "summary": "deletes a model stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.2.5", + "group": "model" + }, + "AI.MODELEXECUTE": { + "summary": "runs a model stored as a key's value using its specified backend and device. It accepts one or more input tensors and store output tensors.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "type": "block", + "block": [ + { + "name": "input_count", + "type": "integer", + "command": "INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + ] + }, + { + "type": "block", + "block": [ + { + "name": "output_count", + "type": "integer", + "command": "OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "timeout", + "command": "TIMEOUT", + "type": "integer", + "optional": true + } + ], + "since": "1.2.5", + "group": "inference" + }, + "AI.MODELGET": { + "summary": "returns a model's metadata and blob stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ], + "optional": true + }, + { + "name": "blob", + "type": "enum", + "enum": [ + "BLOB" + ], + "optional": true + } + ], + "since": "1.2.5", + "group": "model" + }, + "AI.MODELSTORE": { + "summary": "stores a model as the value of a key", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "backend", + "type": "enum", + "enum": [ + "TF", + "TORCH", + "ONNX" + ] + }, + { + "name": "device", + "type": "enum", + "enum": [ + "CPU", + "GPU" + ] + }, + { + "name": "tag", + "command": "TAG", + "type": "string", + "optional": true + }, + { + "name": "batchsize", + "command": "BATCHSIZE ", + "type": "integer", + "optional": true + }, + { + "name": "minbatchsize", + "command": "BATCHSIZE ", + "type": "integer", + "optional": true + }, + { + "name": "minbatchtimeout", + "command": "MINBATCHTIMEOUT ", + "type": "integer", + "optional": true + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "input_count", + "type": "integer", + "command": "INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "output_count", + "type": "integer", + "command": "OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "blob", + "command": "BLOB", + "type": "string", + "optional": true + } + ], + "since": "1.2.5", + "group": "model" + }, + "AI.SCRIPTDEL": { + "summary": "deletes a script stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.SCRIPTEXECUTE": { + "summary": "command runs a script stored as a key's value on its specified device.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "function", + "type": "string" + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "key_count", + "type": "integer", + "command": "KEYS" + }, + { + "name": "key", + "type": "string", + "multiple": true + } + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "input_count", + "type": "integer", + "command": "INPUTS" + }, + { + "name": "input", + "type": "string", + "multiple": true + } + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "arg_count", + "type": "integer", + "command": "ARGS" + }, + { + "name": "arg", + "type": "string", + "multiple": true + } + ] + }, + { + "type": "block", + "optional": true, + "block": [ + { + "name": "output_count", + "type": "integer", + "command": "OUTPUTS" + }, + { + "name": "output", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "timeout", + "command": "TIMEOUT", + "type": "integer", + "optional": true + } + ], + "since": "1.2.5", + "group": "inference" + }, + "AI.SCRIPTGET": { + "summary": "returns the TorchScript stored as a key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ], + "optional": true + }, + { + "name": "source", + "type": "enum", + "enum": [ + "SOURCE" + ], + "optional": true + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.SCRIPTSTORE": { + "summary": "stores a TorchScript as the value of a key.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "device", + "type": "enum", + "enum": [ + "CPU", + "GPU" + ] + }, + { + "name": "tag", + "command": "TAG", + "type": "string", + "optional": true + }, + { + "type": "block", + "block": [ + { + "name": "entry_point_count", + "type": "integer", + "command": "ENTRY_POINTS" + }, + { + "name": "entry_point", + "type": "string", + "multiple": true + } + ] + } + ], + "since": "1.2.5", + "group": "script" + }, + "AI.TENSORGET": { + "summary": "returns a tensor stored as key's value.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "meta", + "type": "enum", + "enum": [ + "META" + ] + }, + { + "name": "format", + "type": "enum", + "enum": [ + "BLOB", + "VALUES" + ], + "optional": true + } + ], + "since": "1.2.5", + "group": "tensor" + }, + "AI.TENSORSET": { + "summary": "stores a tensor as the value of a key.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "type", + "type": "enum", + "enum": [ + "FLOAT", + "DOUBLE", + "INT8", + "INT16", + "INT32", + "INT64", + "UINT8", + "UINT16", + "STRING", + "BOOL" + ] + }, + { + "name": "shape", + "type": "integer", + "multiple": true + }, + { + "name": "blob", + "command": "BLOB", + "type": "string", + "optional": true + }, + { + "name": "value", + "command": "VALUES", + "type": "string", + "multiple": true, + "optional": true + } + ], + "since": "1.2.5", + "group": "tensor" + }, + "APPEND": { + "summary": "Appends a string to the value of a key. Creates the key if it doesn't exist.", + "since": "2.0.0", + "group": "string", + "complexity": "O(1). The amortized time complexity is O(1) assuming the appended value is small and the already present value is of any size, since the dynamic string library used by Redis will double the free space available on every reallocation.", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "ASKING": { + "summary": "Signals that a cluster client is following an -ASK redirect.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 1, + "command_flags": [ + "fast" + ] + }, + "AUTH": { + "summary": "Authenticates the connection.", + "since": "1.0.0", + "group": "connection", + "complexity": "O(N) where N is the number of passwords defined for the user", + "history": [ + [ + "6.0.0", + "Added ACL style (username and password)." + ] + ], + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": -2, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username", + "since": "6.0.0", + "optional": true + }, + { + "name": "password", + "type": "string", + "display_text": "password" + } + ], + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "BF.ADD": { + "summary": "Adds an item to a Bloom Filter", + "complexity": "O(k), where k is the number of hash functions used by the last sub-filter", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.CARD": { + "summary": "Returns the cardinality of a Bloom filter", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.4.4", + "group": "bf" + }, + "BF.EXISTS": { + "summary": "Checks whether an item exists in a Bloom Filter", + "complexity": "O(k), where k is the number of hash functions used by the last sub-filter", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.INFO": { + "summary": "Returns information about a Bloom Filter", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "single_value", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "capacity", + "type": "pure-token", + "token": "CAPACITY" + }, + { + "name": "size", + "type": "pure-token", + "token": "SIZE" + }, + { + "name": "filters", + "type": "pure-token", + "token": "FILTERS" + }, + { + "name": "items", + "type": "pure-token", + "token": "ITEMS" + }, + { + "name": "expansion", + "type": "pure-token", + "token": "EXPANSION" + } + ] + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.INSERT": { + "summary": "Adds one or more items to a Bloom Filter. A filter will be created if it does not exist", + "complexity": "O(k * n), where k is the number of hash functions and n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "capacity", + "type": "integer", + "token": "CAPACITY", + "optional": true + }, + { + "name": "error", + "type": "double", + "token": "ERROR", + "optional": true + }, + { + "name": "expansion", + "type": "integer", + "token": "EXPANSION", + "optional": true + }, + { + "name": "nocreate", + "token": "NOCREATE", + "type": "pure-token", + "optional": true + }, + { + "name": "nonscaling", + "token": "NONSCALING", + "type": "pure-token", + "optional": true + }, + { + "name": "items", + "token": "ITEMS", + "type": "pure-token" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.LOADCHUNK": { + "summary": "Restores a filter previously saved using SCANDUMP", + "complexity": "O(n), where n is the capacity", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "iterator", + "type": "integer" + }, + { + "name": "data", + "type": "string" + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.MADD": { + "summary": "Adds one or more items to a Bloom Filter. A filter will be created if it does not exist", + "complexity": "O(k * n), where k is the number of hash functions and n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.MEXISTS": { + "summary": "Checks whether one or more items exist in a Bloom Filter", + "complexity": "O(k * n), where k is the number of hash functions and n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.RESERVE": { + "summary": "Creates a new Bloom Filter", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "error_rate", + "type": "double" + }, + { + "name": "capacity", + "type": "integer" + }, + { + "name": "expansion", + "type": "integer", + "token": "EXPANSION", + "optional": true + }, + { + "name": "nonscaling", + "type": "pure-token", + "token": "NONSCALING", + "optional": true + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BF.SCANDUMP": { + "summary": "Begins an incremental save of the bloom filter", + "complexity": "O(n), where n is the capacity", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "iterator", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "bf" + }, + "BGREWRITEAOF": { + "summary": "Asynchronously rewrites the append-only file to disk.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "BGSAVE": { + "summary": "Asynchronously saves the database(s) to disk.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "history": [ + [ + "3.2.2", + "Added the `SCHEDULE` option." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "schedule", + "type": "pure-token", + "display_text": "schedule", + "token": "SCHEDULE", + "since": "3.2.2", + "optional": true + } + ], + "command_flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "BITCOUNT": { + "summary": "Counts the number of set bits (population counting) in a string.", + "since": "2.6.0", + "group": "bitmap", + "complexity": "O(N)", + "history": [ + [ + "7.0.0", + "Added the `BYTE|BIT` option." + ] + ], + "acl_categories": [ + "@read", + "@bitmap", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "range", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "end", + "type": "integer", + "display_text": "end" + }, + { + "name": "unit", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "byte", + "type": "pure-token", + "display_text": "byte", + "token": "BYTE" + }, + { + "name": "bit", + "type": "pure-token", + "display_text": "bit", + "token": "BIT" + } + ] + } + ] + } + ], + "command_flags": [ + "readonly" + ] + }, + "BITFIELD": { + "summary": "Performs arbitrary bitfield integer operations on strings.", + "since": "3.2.0", + "group": "bitmap", + "complexity": "O(1) for each subcommand specified", + "acl_categories": [ + "@write", + "@bitmap", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "notes": "This command allows both access and modification of the key", + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true, + "variable_flags": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "operation", + "type": "oneof", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "get-block", + "type": "block", + "token": "GET", + "arguments": [ + { + "name": "encoding", + "type": "string", + "display_text": "encoding" + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + } + ] + }, + { + "name": "write", + "type": "block", + "arguments": [ + { + "name": "overflow-block", + "type": "oneof", + "token": "OVERFLOW", + "optional": true, + "arguments": [ + { + "name": "wrap", + "type": "pure-token", + "display_text": "wrap", + "token": "WRAP" + }, + { + "name": "sat", + "type": "pure-token", + "display_text": "sat", + "token": "SAT" + }, + { + "name": "fail", + "type": "pure-token", + "display_text": "fail", + "token": "FAIL" + } + ] + }, + { + "name": "write-operation", + "type": "oneof", + "arguments": [ + { + "name": "set-block", + "type": "block", + "token": "SET", + "arguments": [ + { + "name": "encoding", + "type": "string", + "display_text": "encoding" + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "value", + "type": "integer", + "display_text": "value" + } + ] + }, + { + "name": "incrby-block", + "type": "block", + "token": "INCRBY", + "arguments": [ + { + "name": "encoding", + "type": "string", + "display_text": "encoding" + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "increment", + "type": "integer", + "display_text": "increment" + } + ] + } + ] + } + ] + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "BITFIELD_RO": { + "summary": "Performs arbitrary read-only bitfield integer operations on strings.", + "since": "6.0.0", + "group": "bitmap", + "complexity": "O(1) for each subcommand specified", + "acl_categories": [ + "@read", + "@bitmap", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "get-block", + "type": "block", + "token": "GET", + "optional": true, + "multiple": true, + "multiple_token": true, + "arguments": [ + { + "name": "encoding", + "type": "string", + "display_text": "encoding" + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + } + ] + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "BITOP": { + "summary": "Performs bitwise operations on multiple strings, and stores the result.", + "since": "2.6.0", + "group": "bitmap", + "complexity": "O(N)", + "acl_categories": [ + "@write", + "@bitmap", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 3 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "operation", + "type": "oneof", + "arguments": [ + { + "name": "and", + "type": "pure-token", + "display_text": "and", + "token": "AND" + }, + { + "name": "or", + "type": "pure-token", + "display_text": "or", + "token": "OR" + }, + { + "name": "xor", + "type": "pure-token", + "display_text": "xor", + "token": "XOR" + }, + { + "name": "not", + "type": "pure-token", + "display_text": "not", + "token": "NOT" + } + ] + }, + { + "name": "destkey", + "type": "key", + "display_text": "destkey", + "key_spec_index": 0 + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "BITPOS": { + "summary": "Finds the first set (1) or clear (0) bit in a string.", + "since": "2.8.7", + "group": "bitmap", + "complexity": "O(N)", + "history": [ + [ + "7.0.0", + "Added the `BYTE|BIT` option." + ] + ], + "acl_categories": [ + "@read", + "@bitmap", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "bit", + "type": "integer", + "display_text": "bit" + }, + { + "name": "range", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "end-unit-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "end", + "type": "integer", + "display_text": "end" + }, + { + "name": "unit", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "byte", + "type": "pure-token", + "display_text": "byte", + "token": "BYTE" + }, + { + "name": "bit", + "type": "pure-token", + "display_text": "bit", + "token": "BIT" + } + ] + } + ] + } + ] + } + ], + "command_flags": [ + "readonly" + ] + }, + "BLMOVE": { + "summary": "Pops an element from a list, pushes it to another list and returns it. Blocks until an element is available otherwise. Deletes the list if the last element was moved.", + "since": "6.2.0", + "group": "list", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@list", + "@slow", + "@blocking" + ], + "arity": 6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + }, + { + "name": "wherefrom", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + }, + { + "name": "whereto", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "denyoom", + "blocking" + ] + }, + "BLMPOP": { + "summary": "Pops the first element from one of multiple lists. Blocks until an element is available otherwise. Deletes the list if the last element was popped.", + "since": "7.0.0", + "group": "list", + "complexity": "O(N+M) where N is the number of provided keys and M is the number of elements returned.", + "acl_categories": [ + "@write", + "@list", + "@slow", + "@blocking" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "where", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "BLPOP": { + "summary": "Removes and returns the first element in a list. Blocks until an element is available otherwise. Deletes the list if the last element was popped.", + "since": "2.0.0", + "group": "list", + "complexity": "O(N) where N is the number of provided keys.", + "history": [ + [ + "6.0.0", + "`timeout` is interpreted as a double instead of an integer." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@slow", + "@blocking" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -2, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "blocking" + ] + }, + "BRPOP": { + "summary": "Removes and returns the last element in a list. Blocks until an element is available otherwise. Deletes the list if the last element was popped.", + "since": "2.0.0", + "group": "list", + "complexity": "O(N) where N is the number of provided keys.", + "history": [ + [ + "6.0.0", + "`timeout` is interpreted as a double instead of an integer." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@slow", + "@blocking" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -2, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "blocking" + ] + }, + "BRPOPLPUSH": { + "summary": "Pops an element from a list, pushes it to another list and returns it. Block until an element is available otherwise. Deletes the list if the last element was popped.", + "since": "2.2.0", + "group": "list", + "complexity": "O(1)", + "deprecated_since": "6.2.0", + "replaced_by": "`BLMOVE` with the `RIGHT` and `LEFT` arguments", + "history": [ + [ + "6.0.0", + "`timeout` is interpreted as a double instead of an integer." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@slow", + "@blocking" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "denyoom", + "blocking" + ], + "doc_flags": [ + "deprecated" + ] + }, + "BZMPOP": { + "summary": "Removes and returns a member by score from one or more sorted sets. Blocks until a member is available otherwise. Deletes the sorted set if the last element was popped.", + "since": "7.0.0", + "group": "sorted-set", + "complexity": "O(K) + O(M*log(N)) where K is the number of provided keys, N being the number of elements in the sorted set, and M being the number of elements popped.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow", + "@blocking" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "where", + "type": "oneof", + "arguments": [ + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "BZPOPMAX": { + "summary": "Removes and returns the member with the highest score from one or more sorted sets. Blocks until a member available otherwise. Deletes the sorted set if the last element was popped.", + "since": "5.0.0", + "group": "sorted-set", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "history": [ + [ + "6.0.0", + "`timeout` is interpreted as a double instead of an integer." + ] + ], + "acl_categories": [ + "@write", + "@sortedset", + "@fast", + "@blocking" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -2, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "blocking", + "fast" + ] + }, + "BZPOPMIN": { + "summary": "Removes and returns the member with the lowest score from one or more sorted sets. Blocks until a member is available otherwise. Deletes the sorted set if the last element was popped.", + "since": "5.0.0", + "group": "sorted-set", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "history": [ + [ + "6.0.0", + "`timeout` is interpreted as a double instead of an integer." + ] + ], + "acl_categories": [ + "@write", + "@sortedset", + "@fast", + "@blocking" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -2, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "timeout", + "type": "double", + "display_text": "timeout" + } + ], + "command_flags": [ + "write", + "blocking", + "fast" + ] + }, + "CF.ADD": { + "summary": "Adds an item to a Cuckoo Filter", + "complexity": "O(k + i), where k is the number of sub-filters and i is maxIterations", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.ADDNX": { + "summary": "Adds an item to a Cuckoo Filter if the item did not exist previously.", + "complexity": "O(k + i), where k is the number of sub-filters and i is maxIterations", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.COUNT": { + "summary": "Return the number of times an item might be in a Cuckoo Filter", + "complexity": "O(k), where k is the number of sub-filters", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.DEL": { + "summary": "Deletes an item from a Cuckoo Filter", + "complexity": "O(k), where k is the number of sub-filters", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.EXISTS": { + "summary": "Checks whether one or more items exist in a Cuckoo Filter", + "complexity": "O(k), where k is the number of sub-filters", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.INFO": { + "summary": "Returns information about a Cuckoo Filter", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.INSERT": { + "summary": "Adds one or more items to a Cuckoo Filter. A filter will be created if it does not exist", + "complexity": "O(n * (k + i)), where n is the number of items, k is the number of sub-filters and i is maxIterations", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "capacity", + "type": "integer", + "token": "CAPACITY", + "optional": true + }, + { + "name": "nocreate", + "token": "NOCREATE", + "type": "pure-token", + "optional": true + }, + { + "name": "items", + "token": "ITEMS", + "type": "pure-token" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.INSERTNX": { + "summary": "Adds one or more items to a Cuckoo Filter if the items did not exist previously. A filter will be created if it does not exist", + "complexity": "O(n * (k + i)), where n is the number of items, k is the number of sub-filters and i is maxIterations", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "capacity", + "type": "integer", + "token": "CAPACITY", + "optional": true + }, + { + "name": "nocreate", + "token": "NOCREATE", + "type": "pure-token", + "optional": true + }, + { + "name": "items", + "token": "ITEMS", + "type": "pure-token" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.LOADCHUNK": { + "summary": "Restores a filter previously saved using SCANDUMP", + "complexity": "O(n), where n is the capacity", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "iterator", + "type": "integer" + }, + { + "name": "data", + "type": "string" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.MEXISTS": { + "summary": "Checks whether one or more items exist in a Cuckoo Filter", + "complexity": "O(k * n), where k is the number of sub-filters and n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.RESERVE": { + "summary": "Creates a new Cuckoo Filter", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "capacity", + "type": "integer" + }, + { + "name": "bucketsize", + "type": "integer", + "token": "BUCKETSIZE", + "optional": true + }, + { + "name": "maxiterations", + "type": "integer", + "token": "MAXITERATIONS", + "optional": true + }, + { + "name": "expansion", + "type": "integer", + "token": "EXPANSION", + "optional": true + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CF.SCANDUMP": { + "summary": "Begins an incremental save of the bloom filter", + "complexity": "O(n), where n is the capacity", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "iterator", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "cf" + }, + "CLIENT": { + "summary": "A container for client connection commands.", + "since": "2.4.0", + "group": "connection", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "CLIENT CACHING": { + "summary": "Instructs the server whether to track the keys in the next request.", + "since": "6.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "mode", + "type": "oneof", + "arguments": [ + { + "name": "yes", + "type": "pure-token", + "display_text": "yes", + "token": "YES" + }, + { + "name": "no", + "type": "pure-token", + "display_text": "no", + "token": "NO" + } + ] + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT GETNAME": { + "summary": "Returns the name of the connection.", + "since": "2.6.9", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT GETREDIR": { + "summary": "Returns the client ID to which the connection's tracking notifications are redirected.", + "since": "6.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "CLIENT ID": { + "summary": "Returns the unique client ID of the connection.", + "since": "5.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT INFO": { + "summary": "Returns information about the connection.", + "since": "6.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLIENT KILL": { + "summary": "Terminates open connections.", + "since": "2.4.0", + "group": "connection", + "complexity": "O(N) where N is the number of client connections", + "history": [ + [ + "2.8.12", + "Added new filter format." + ], + [ + "2.8.12", + "`ID` option." + ], + [ + "3.2.0", + "Added `master` type in for `TYPE` option." + ], + [ + "5.0.0", + "Replaced `slave` `TYPE` with `replica`. `slave` still supported for backward compatibility." + ], + [ + "6.2.0", + "`LADDR` option." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "filter", + "type": "oneof", + "arguments": [ + { + "name": "old-format", + "type": "string", + "display_text": "ip:port", + "deprecated_since": "2.8.12" + }, + { + "name": "new-format", + "type": "oneof", + "multiple": true, + "arguments": [ + { + "name": "client-id", + "type": "integer", + "display_text": "client-id", + "token": "ID", + "since": "2.8.12", + "optional": true + }, + { + "name": "client-type", + "type": "oneof", + "token": "TYPE", + "since": "2.8.12", + "optional": true, + "arguments": [ + { + "name": "normal", + "type": "pure-token", + "display_text": "normal", + "token": "NORMAL" + }, + { + "name": "master", + "type": "pure-token", + "display_text": "master", + "token": "MASTER", + "since": "3.2.0" + }, + { + "name": "slave", + "type": "pure-token", + "display_text": "slave", + "token": "SLAVE" + }, + { + "name": "replica", + "type": "pure-token", + "display_text": "replica", + "token": "REPLICA", + "since": "5.0.0" + }, + { + "name": "pubsub", + "type": "pure-token", + "display_text": "pubsub", + "token": "PUBSUB" + } + ] + }, + { + "name": "username", + "type": "string", + "display_text": "username", + "token": "USER", + "optional": true + }, + { + "name": "addr", + "type": "string", + "display_text": "ip:port", + "token": "ADDR", + "optional": true + }, + { + "name": "laddr", + "type": "string", + "display_text": "ip:port", + "token": "LADDR", + "since": "6.2.0", + "optional": true + }, + { + "name": "skipme", + "type": "oneof", + "token": "SKIPME", + "optional": true, + "arguments": [ + { + "name": "yes", + "type": "pure-token", + "display_text": "yes", + "token": "YES" + }, + { + "name": "no", + "type": "pure-token", + "display_text": "no", + "token": "NO" + } + ] + } + ] + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CLIENT LIST": { + "summary": "Lists open connections.", + "since": "2.4.0", + "group": "connection", + "complexity": "O(N) where N is the number of client connections", + "history": [ + [ + "2.8.12", + "Added unique client `id` field." + ], + [ + "5.0.0", + "Added optional `TYPE` filter." + ], + [ + "6.0.0", + "Added `user` field." + ], + [ + "6.2.0", + "Added `argv-mem`, `tot-mem`, `laddr` and `redir` fields and the optional `ID` filter." + ], + [ + "7.0.0", + "Added `resp`, `multi-mem`, `rbs` and `rbp` fields." + ], + [ + "7.0.3", + "Added `ssub` field." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": -2, + "arguments": [ + { + "name": "client-type", + "type": "oneof", + "token": "TYPE", + "since": "5.0.0", + "optional": true, + "arguments": [ + { + "name": "normal", + "type": "pure-token", + "display_text": "normal", + "token": "NORMAL" + }, + { + "name": "master", + "type": "pure-token", + "display_text": "master", + "token": "MASTER" + }, + { + "name": "replica", + "type": "pure-token", + "display_text": "replica", + "token": "REPLICA" + }, + { + "name": "pubsub", + "type": "pure-token", + "display_text": "pubsub", + "token": "PUBSUB" + } + ] + }, + { + "name": "client-id", + "type": "integer", + "display_text": "client-id", + "token": "ID", + "since": "6.2.0", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLIENT NO-EVICT": { + "summary": "Sets the client eviction mode of the connection.", + "since": "7.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "enabled", + "type": "oneof", + "arguments": [ + { + "name": "on", + "type": "pure-token", + "display_text": "on", + "token": "ON" + }, + { + "name": "off", + "type": "pure-token", + "display_text": "off", + "token": "OFF" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CLIENT NO-TOUCH": { + "summary": "Controls whether commands sent by the client affect the LRU/LFU of accessed keys.", + "since": "7.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "enabled", + "type": "oneof", + "arguments": [ + { + "name": "on", + "type": "pure-token", + "display_text": "on", + "token": "ON" + }, + { + "name": "off", + "type": "pure-token", + "display_text": "off", + "token": "OFF" + } + ] + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT PAUSE": { + "summary": "Suspends commands processing.", + "since": "3.0.0", + "group": "connection", + "complexity": "O(1)", + "history": [ + [ + "6.2.0", + "`CLIENT PAUSE WRITE` mode added along with the `mode` option." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "timeout", + "type": "integer", + "display_text": "timeout" + }, + { + "name": "mode", + "type": "oneof", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "write", + "type": "pure-token", + "display_text": "write", + "token": "WRITE" + }, + { + "name": "all", + "type": "pure-token", + "display_text": "all", + "token": "ALL" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CLIENT REPLY": { + "summary": "Instructs the server whether to reply to commands.", + "since": "3.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "action", + "type": "oneof", + "arguments": [ + { + "name": "on", + "type": "pure-token", + "display_text": "on", + "token": "ON" + }, + { + "name": "off", + "type": "pure-token", + "display_text": "off", + "token": "OFF" + }, + { + "name": "skip", + "type": "pure-token", + "display_text": "skip", + "token": "SKIP" + } + ] + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT SETINFO": { + "summary": "Sets information specific to the client or connection.", + "since": "7.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 4, + "arguments": [ + { + "name": "attr", + "type": "oneof", + "arguments": [ + { + "name": "libname", + "type": "string", + "display_text": "libname", + "token": "LIB-NAME" + }, + { + "name": "libver", + "type": "string", + "display_text": "libver", + "token": "LIB-VER" + } + ] + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "CLIENT SETNAME": { + "summary": "Sets the connection name.", + "since": "2.6.9", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "connection-name", + "type": "string", + "display_text": "connection-name" + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "CLIENT TRACKING": { + "summary": "Controls server-assisted client-side caching for the connection.", + "since": "6.0.0", + "group": "connection", + "complexity": "O(1). Some options may introduce additional complexity.", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "status", + "type": "oneof", + "arguments": [ + { + "name": "on", + "type": "pure-token", + "display_text": "on", + "token": "ON" + }, + { + "name": "off", + "type": "pure-token", + "display_text": "off", + "token": "OFF" + } + ] + }, + { + "name": "client-id", + "type": "integer", + "display_text": "client-id", + "token": "REDIRECT", + "optional": true + }, + { + "name": "prefix", + "type": "string", + "display_text": "prefix", + "token": "PREFIX", + "optional": true, + "multiple": true, + "multiple_token": true + }, + { + "name": "bcast", + "type": "pure-token", + "display_text": "bcast", + "token": "BCAST", + "optional": true + }, + { + "name": "optin", + "type": "pure-token", + "display_text": "optin", + "token": "OPTIN", + "optional": true + }, + { + "name": "optout", + "type": "pure-token", + "display_text": "optout", + "token": "OPTOUT", + "optional": true + }, + { + "name": "noloop", + "type": "pure-token", + "display_text": "noloop", + "token": "NOLOOP", + "optional": true + } + ], + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT TRACKINGINFO": { + "summary": "Returns information about server-assisted client-side caching for the connection.", + "since": "6.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "noscript", + "loading", + "stale" + ] + }, + "CLIENT UNBLOCK": { + "summary": "Unblocks a client blocked by a blocking command from a different connection.", + "since": "5.0.0", + "group": "connection", + "complexity": "O(log N) where N is the number of client connections", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "client-id", + "type": "integer", + "display_text": "client-id" + }, + { + "name": "unblock-type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "timeout", + "type": "pure-token", + "display_text": "timeout", + "token": "TIMEOUT" + }, + { + "name": "error", + "type": "pure-token", + "display_text": "error", + "token": "ERROR" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CLIENT UNPAUSE": { + "summary": "Resumes processing commands from paused clients.", + "since": "6.2.0", + "group": "connection", + "complexity": "O(N) Where N is the number of paused clients", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous", + "@connection" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CLUSTER": { + "summary": "A container for Redis Cluster commands.", + "since": "3.0.0", + "group": "cluster", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "CLUSTER ADDSLOTS": { + "summary": "Assigns new hash slots to a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of hash slot arguments", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "slot", + "type": "integer", + "display_text": "slot", + "multiple": true + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER ADDSLOTSRANGE": { + "summary": "Assigns new hash slot ranges to a node.", + "since": "7.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of the slots between the start slot and end slot arguments.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "range", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "start-slot", + "type": "integer", + "display_text": "start-slot" + }, + { + "name": "end-slot", + "type": "integer", + "display_text": "end-slot" + } + ] + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER BUMPEPOCH": { + "summary": "Advances the cluster config epoch.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER COUNT-FAILURE-REPORTS": { + "summary": "Returns the number of active failure reports active for a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of failure reports", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "node-id", + "type": "string", + "display_text": "node-id" + } + ], + "command_flags": [ + "admin", + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER COUNTKEYSINSLOT": { + "summary": "Returns the number of keys in a hash slot.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 3, + "arguments": [ + { + "name": "slot", + "type": "integer", + "display_text": "slot" + } + ], + "command_flags": [ + "stale" + ] + }, + "CLUSTER DELSLOTS": { + "summary": "Sets hash slots as unbound for a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of hash slot arguments", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "slot", + "type": "integer", + "display_text": "slot", + "multiple": true + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER DELSLOTSRANGE": { + "summary": "Sets hash slot ranges as unbound for a node.", + "since": "7.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of the slots between the start slot and end slot arguments.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "range", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "start-slot", + "type": "integer", + "display_text": "start-slot" + }, + { + "name": "end-slot", + "type": "integer", + "display_text": "end-slot" + } + ] + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER FAILOVER": { + "summary": "Forces a replica to perform a manual failover of its master.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "options", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "force", + "type": "pure-token", + "display_text": "force", + "token": "FORCE" + }, + { + "name": "takeover", + "type": "pure-token", + "display_text": "takeover", + "token": "TAKEOVER" + } + ] + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER FLUSHSLOTS": { + "summary": "Deletes all slots information from a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER FORGET": { + "summary": "Removes a node from the nodes table.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "node-id", + "type": "string", + "display_text": "node-id" + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER GETKEYSINSLOT": { + "summary": "Returns the key names in a hash slot.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of requested keys", + "acl_categories": [ + "@slow" + ], + "arity": 4, + "arguments": [ + { + "name": "slot", + "type": "integer", + "display_text": "slot" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ], + "command_flags": [ + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "CLUSTER INFO": { + "summary": "Returns information about the state of a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER KEYSLOT": { + "summary": "Returns the hash slot for a key.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of bytes in the key", + "acl_categories": [ + "@slow" + ], + "arity": 3, + "arguments": [ + { + "name": "key", + "type": "string", + "display_text": "key" + } + ], + "command_flags": [ + "stale" + ] + }, + "CLUSTER LINKS": { + "summary": "Returns a list of all TCP links to and from peer nodes.", + "since": "7.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of Cluster nodes", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER MEET": { + "summary": "Forces a node to handshake with another node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "history": [ + [ + "4.0.0", + "Added the optional `cluster_bus_port` argument." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "ip", + "type": "string", + "display_text": "ip" + }, + { + "name": "port", + "type": "integer", + "display_text": "port" + }, + { + "name": "cluster-bus-port", + "type": "integer", + "display_text": "cluster-bus-port", + "since": "4.0.0", + "optional": true + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER MYID": { + "summary": "Returns the ID of a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "stale" + ] + }, + "CLUSTER MYSHARDID": { + "summary": "Returns the shard ID of a node.", + "since": "7.2.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER NODES": { + "summary": "Returns the cluster configuration for a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of Cluster nodes", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER REPLICAS": { + "summary": "Lists the replica nodes of a master node.", + "since": "5.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of replicas.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "node-id", + "type": "string", + "display_text": "node-id" + } + ], + "command_flags": [ + "admin", + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER REPLICATE": { + "summary": "Configure a node as replica of a master node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "node-id", + "type": "string", + "display_text": "node-id" + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER RESET": { + "summary": "Resets a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of known nodes. The command may execute a FLUSHALL as a side effect.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "reset-type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "hard", + "type": "pure-token", + "display_text": "hard", + "token": "HARD" + }, + { + "name": "soft", + "type": "pure-token", + "display_text": "soft", + "token": "SOFT" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "stale" + ] + }, + "CLUSTER SAVECONFIG": { + "summary": "Forces a node to save the cluster configuration to disk.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER SET-CONFIG-EPOCH": { + "summary": "Sets the configuration epoch for a new node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "config-epoch", + "type": "integer", + "display_text": "config-epoch" + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER SETSLOT": { + "summary": "Binds a hash slot to a node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "slot", + "type": "integer", + "display_text": "slot" + }, + { + "name": "subcommand", + "type": "oneof", + "arguments": [ + { + "name": "importing", + "type": "string", + "display_text": "node-id", + "token": "IMPORTING" + }, + { + "name": "migrating", + "type": "string", + "display_text": "node-id", + "token": "MIGRATING" + }, + { + "name": "node", + "type": "string", + "display_text": "node-id", + "token": "NODE" + }, + { + "name": "stable", + "type": "pure-token", + "display_text": "stable", + "token": "STABLE" + } + ] + } + ], + "command_flags": [ + "admin", + "stale", + "no_async_loading" + ] + }, + "CLUSTER SHARDS": { + "summary": "Returns the mapping of cluster slots to shards.", + "since": "7.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of cluster nodes", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER SLAVES": { + "summary": "Lists the replica nodes of a master node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the number of replicas.", + "deprecated_since": "5.0.0", + "replaced_by": "`CLUSTER REPLICAS`", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "node-id", + "type": "string", + "display_text": "node-id" + } + ], + "command_flags": [ + "admin", + "stale" + ], + "doc_flags": [ + "deprecated" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CLUSTER SLOTS": { + "summary": "Returns the mapping of cluster slots to nodes.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(N) where N is the total number of Cluster nodes", + "deprecated_since": "7.0.0", + "replaced_by": "`CLUSTER SHARDS`", + "history": [ + [ + "4.0.0", + "Added node IDs." + ], + [ + "7.0.0", + "Added additional networking metadata field." + ] + ], + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ], + "doc_flags": [ + "deprecated" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "CMS.INCRBY": { + "summary": "Increases the count of one or more items by increment", + "complexity": "O(n) where n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "items", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "item", + "type": "string" + }, + { + "name": "increment", + "type": "integer" + } + ] + } + ], + "since": "2.0.0", + "group": "cms" + }, + "CMS.INFO": { + "summary": "Returns information about a sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "cms" + }, + "CMS.INITBYDIM": { + "summary": "Initializes a Count-Min Sketch to dimensions specified by user", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "width", + "type": "integer" + }, + { + "name": "depth", + "type": "integer" + } + ], + "since": "2.0.0", + "group": "cms" + }, + "CMS.INITBYPROB": { + "summary": "Initializes a Count-Min Sketch to accommodate requested tolerances.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "error", + "type": "double" + }, + { + "name": "probability", + "type": "double" + } + ], + "since": "2.0.0", + "group": "cms" + }, + "CMS.MERGE": { + "summary": "Merges several sketches into one sketch", + "complexity": "O(n) where n is the number of sketches", + "arguments": [ + { + "name": "destination", + "type": "key" + }, + { + "name": "numKeys", + "type": "integer" + }, + { + "name": "source", + "type": "key", + "multiple": true + }, + { + "name": "weight", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "weights", + "token": "WEIGHTS", + "type": "pure-token" + }, + { + "name": "weight", + "type": "double", + "multiple": true + } + ] + } + ], + "since": "2.0.0", + "group": "cms" + }, + "CMS.QUERY": { + "summary": "Returns the count for one or more items in a sketch", + "complexity": "O(n) where n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "cms" + }, + "COMMAND": { + "summary": "Returns detailed information about all commands.", + "since": "2.8.13", + "group": "server", + "complexity": "O(N) where N is the total number of Redis commands", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -1, + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "COMMAND COUNT": { + "summary": "Returns a count of commands.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "COMMAND DOCS": { + "summary": "Returns documentary information about one, multiple or all commands.", + "since": "7.0.0", + "group": "server", + "complexity": "O(N) where N is the number of commands to look up", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -2, + "arguments": [ + { + "name": "command-name", + "type": "string", + "display_text": "command-name", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "COMMAND GETKEYS": { + "summary": "Extracts the key names from an arbitrary command.", + "since": "2.8.13", + "group": "server", + "complexity": "O(N) where N is the number of arguments to the command", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "command", + "type": "string", + "display_text": "command" + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "loading", + "stale" + ] + }, + "COMMAND GETKEYSANDFLAGS": { + "summary": "Extracts the key names and access flags for an arbitrary command.", + "since": "7.0.0", + "group": "server", + "complexity": "O(N) where N is the number of arguments to the command", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -3, + "arguments": [ + { + "name": "command", + "type": "string", + "display_text": "command" + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "loading", + "stale" + ] + }, + "COMMAND HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "COMMAND INFO": { + "summary": "Returns information about one, multiple or all commands.", + "since": "2.8.13", + "group": "server", + "complexity": "O(N) where N is the number of commands to look up", + "history": [ + [ + "7.0.0", + "Allowed to be called with no argument to get info on all commands." + ] + ], + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -2, + "arguments": [ + { + "name": "command-name", + "type": "string", + "display_text": "command-name", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "COMMAND LIST": { + "summary": "Returns a list of command names.", + "since": "7.0.0", + "group": "server", + "complexity": "O(N) where N is the total number of Redis commands", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": -2, + "arguments": [ + { + "name": "filterby", + "type": "oneof", + "token": "FILTERBY", + "optional": true, + "arguments": [ + { + "name": "module-name", + "type": "string", + "display_text": "module-name", + "token": "MODULE" + }, + { + "name": "category", + "type": "string", + "display_text": "category", + "token": "ACLCAT" + }, + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "token": "PATTERN" + } + ] + } + ], + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "CONFIG": { + "summary": "A container for server configuration commands.", + "since": "2.0.0", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "CONFIG GET": { + "summary": "Returns the effective values of configuration parameters.", + "since": "2.0.0", + "group": "server", + "complexity": "O(N) when N is the number of configuration parameters provided", + "history": [ + [ + "7.0.0", + "Added the ability to pass multiple pattern parameters in one call" + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "parameter", + "type": "string", + "display_text": "parameter", + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "CONFIG HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "CONFIG RESETSTAT": { + "summary": "Resets the server's statistics.", + "since": "2.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "CONFIG REWRITE": { + "summary": "Persists the effective configuration to file.", + "since": "2.8.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "CONFIG SET": { + "summary": "Sets configuration parameters in-flight.", + "since": "2.0.0", + "group": "server", + "complexity": "O(N) when N is the number of configuration parameters provided", + "history": [ + [ + "7.0.0", + "Added the ability to set multiple parameters in one call." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -4, + "arguments": [ + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "parameter", + "type": "string", + "display_text": "parameter" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "COPY": { + "summary": "Copies the value of a key to a new key.", + "since": "6.2.0", + "group": "generic", + "complexity": "O(N) worst case for collections, where N is the number of nested items. O(1) for string values.", + "acl_categories": [ + "@keyspace", + "@write", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + }, + { + "name": "destination-db", + "type": "integer", + "display_text": "destination-db", + "token": "DB", + "optional": true + }, + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "DBSIZE": { + "summary": "Returns the number of keys in the database.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 1, + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:agg_sum" + ] + }, + "DEBUG": { + "summary": "A container for debugging commands.", + "since": "1.0.0", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "doc_flags": [ + "syscmd" + ] + }, + "DECR": { + "summary": "Decrements the integer value of a key by one. Uses 0 as initial value if the key doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "DECRBY": { + "summary": "Decrements a number from the integer value of a key. Uses 0 as initial value if the key doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "decrement", + "type": "integer", + "display_text": "decrement" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "DEL": { + "summary": "Deletes one or more keys.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(N) where N is the number of keys that will be removed. When a key to remove holds a value other than a string, the individual complexity for this key is O(M) where M is the number of elements in the list, set, sorted set or hash. Removing a single key that holds a string value is O(1).", + "acl_categories": [ + "@keyspace", + "@write", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RM": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "write" + ], + "hints": [ + "request_policy:multi_shard", + "response_policy:agg_sum" + ] + }, + "DISCARD": { + "summary": "Discards a transaction.", + "since": "2.0.0", + "group": "transactions", + "complexity": "O(N), when N is the number of queued commands", + "acl_categories": [ + "@fast", + "@transaction" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "DUMP": { + "summary": "Returns a serialized representation of the value stored at a key.", + "since": "2.6.0", + "group": "generic", + "complexity": "O(1) to access the key and additional O(N*M) to serialize it, where N is the number of Redis objects composing the value and M their average size. For small string values the time complexity is thus O(1)+O(1*M) where M is small, so simply O(1).", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "ECHO": { + "summary": "Returns the given string.", + "since": "1.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 2, + "arguments": [ + { + "name": "message", + "type": "string", + "display_text": "message" + } + ], + "command_flags": [ + "loading", + "stale", + "fast" + ] + }, + "EVAL": { + "summary": "Executes a server-side Lua script.", + "since": "2.6.0", + "group": "scripting", + "complexity": "Depends on the script that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "notes": "We cannot tell how the keys will be used so we assume the worst, RW and UPDATE", + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "script", + "type": "string", + "display_text": "script" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "EVALSHA": { + "summary": "Executes a server-side Lua script by SHA1 digest.", + "since": "2.6.0", + "group": "scripting", + "complexity": "Depends on the script that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "sha1", + "type": "string", + "display_text": "sha1" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "EVALSHA_RO": { + "summary": "Executes a read-only server-side Lua script by SHA1 digest.", + "since": "7.0.0", + "group": "scripting", + "complexity": "Depends on the script that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "sha1", + "type": "string", + "display_text": "sha1" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "EVAL_RO": { + "summary": "Executes a read-only server-side Lua script.", + "since": "7.0.0", + "group": "scripting", + "complexity": "Depends on the script that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "notes": "We cannot tell how the keys will be used so we assume the worst, RO and ACCESS", + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "script", + "type": "string", + "display_text": "script" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "EXEC": { + "summary": "Executes all commands in a transaction.", + "since": "1.2.0", + "group": "transactions", + "complexity": "Depends on commands in the transaction", + "acl_categories": [ + "@slow", + "@transaction" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "skip_slowlog" + ] + }, + "EXISTS": { + "summary": "Determines whether one or more keys exist.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(N) where N is the number of keys to check.", + "history": [ + [ + "3.0.3", + "Accepts multiple `key` arguments." + ] + ], + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "request_policy:multi_shard", + "response_policy:agg_sum" + ] + }, + "EXPIRE": { + "summary": "Sets the expiration time of a key in seconds.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added options: `NX`, `XX`, `GT` and `LT`." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "seconds", + "type": "integer", + "display_text": "seconds" + }, + { + "name": "condition", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + }, + { + "name": "gt", + "type": "pure-token", + "display_text": "gt", + "token": "GT" + }, + { + "name": "lt", + "type": "pure-token", + "display_text": "lt", + "token": "LT" + } + ] + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "EXPIREAT": { + "summary": "Sets the expiration time of a key to a Unix timestamp.", + "since": "1.2.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added options: `NX`, `XX`, `GT` and `LT`." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "unix-time-seconds", + "type": "unix-time", + "display_text": "unix-time-seconds" + }, + { + "name": "condition", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + }, + { + "name": "gt", + "type": "pure-token", + "display_text": "gt", + "token": "GT" + }, + { + "name": "lt", + "type": "pure-token", + "display_text": "lt", + "token": "LT" + } + ] + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "EXPIRETIME": { + "summary": "Returns the expiration time of a key as a Unix timestamp.", + "since": "7.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "FAILOVER": { + "summary": "Starts a coordinated failover from a server to one of its replicas.", + "since": "6.2.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "target", + "type": "block", + "token": "TO", + "optional": true, + "arguments": [ + { + "name": "host", + "type": "string", + "display_text": "host" + }, + { + "name": "port", + "type": "integer", + "display_text": "port" + }, + { + "name": "force", + "type": "pure-token", + "display_text": "force", + "token": "FORCE", + "optional": true + } + ] + }, + { + "name": "abort", + "type": "pure-token", + "display_text": "abort", + "token": "ABORT", + "optional": true + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds", + "token": "TIMEOUT", + "optional": true + } + ], + "command_flags": [ + "admin", + "noscript", + "stale" + ] + }, + "FCALL": { + "summary": "Invokes a function.", + "since": "7.0.0", + "group": "scripting", + "complexity": "Depends on the function that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "notes": "We cannot tell how the keys will be used so we assume the worst, RW and UPDATE", + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "function", + "type": "string", + "display_text": "function" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "FCALL_RO": { + "summary": "Invokes a read-only function.", + "since": "7.0.0", + "group": "scripting", + "complexity": "Depends on the function that is executed.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "key_specs": [ + { + "notes": "We cannot tell how the keys will be used so we assume the worst, RO and ACCESS", + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "function", + "type": "string", + "display_text": "function" + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "optional": true, + "multiple": true + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "noscript", + "stale", + "skip_monitor", + "no_mandatory_keys", + "movablekeys" + ] + }, + "FLUSHALL": { + "summary": "Removes all keys from all databases.", + "since": "1.0.0", + "group": "server", + "complexity": "O(N) where N is the total number of keys in all databases", + "history": [ + [ + "4.0.0", + "Added the `ASYNC` flushing mode modifier." + ], + [ + "6.2.0", + "Added the `SYNC` flushing mode modifier." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "flush-type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "async", + "type": "pure-token", + "display_text": "async", + "token": "ASYNC", + "since": "4.0.0" + }, + { + "name": "sync", + "type": "pure-token", + "display_text": "sync", + "token": "SYNC", + "since": "6.2.0" + } + ] + } + ], + "command_flags": [ + "write" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FLUSHDB": { + "summary": "Remove all keys from the current database.", + "since": "1.0.0", + "group": "server", + "complexity": "O(N) where N is the number of keys in the selected database", + "history": [ + [ + "4.0.0", + "Added the `ASYNC` flushing mode modifier." + ], + [ + "6.2.0", + "Added the `SYNC` flushing mode modifier." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "flush-type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "async", + "type": "pure-token", + "display_text": "async", + "token": "ASYNC", + "since": "4.0.0" + }, + { + "name": "sync", + "type": "pure-token", + "display_text": "sync", + "token": "SYNC", + "since": "6.2.0" + } + ] + } + ], + "command_flags": [ + "write" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FT.AGGREGATE": { + "summary": "Run a search query on an index and perform aggregate transformations on the results", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + }, + { + "name": "verbatim", + "type": "pure-token", + "token": "VERBATIM", + "optional": true, + "summary": "Searches using the exact query terms without stemming or synonym expansion." + }, + { + "name": "load", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "LOAD" + }, + { + "name": "field", + "type": "string", + "multiple": true, + "summary": "Specifies a field in the index schema with its properties." + } + ] + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT", + "summary": "Sets a time limit for query execution, specified in milliseconds." + }, + { + "name": "loadall", + "type": "pure-token", + "token": "LOAD *", + "optional": true + }, + { + "name": "groupby", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "nargs", + "type": "integer", + "token": "GROUPBY" + }, + { + "name": "property", + "type": "string", + "multiple": true + }, + { + "name": "reduce", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "reduce", + "token": "REDUCE", + "type": "pure-token" + }, + { + "name": "function", + "type": "oneof", + "arguments": [ + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "count_distinct", + "type": "pure-token", + "token": "COUNT_DISTINCT" + }, + { + "name": "count_distinctish", + "type": "pure-token", + "token": "COUNT_DISTINCTISH" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "stddev", + "type": "pure-token", + "token": "STDDEV" + }, + { + "name": "quantile", + "type": "pure-token", + "token": "QUANTILE" + }, + { + "name": "tolist", + "type": "pure-token", + "token": "TOLIST" + }, + { + "name": "first_value", + "type": "pure-token", + "token": "FIRST_VALUE" + }, + { + "name": "random_sample", + "type": "pure-token", + "token": "RANDOM_SAMPLE" + } + ] + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "arg", + "type": "string", + "multiple": true + }, + { + "name": "name", + "type": "string", + "token": "AS", + "optional": true + } + ], + "summary": "Applies a reducer function, like `SUM` or `COUNT`, on grouped results." + } + ], + "summary": "Groups results by specified fields, often used for aggregations." + }, + { + "name": "sortby", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "nargs", + "type": "integer", + "token": "SORTBY" + }, + { + "name": "fields", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "property", + "type": "string" + }, + { + "name": "order", + "type": "oneof", + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "token": "DESC" + } + ] + } + ] + }, + { + "name": "num", + "type": "integer", + "token": "MAX", + "optional": true + } + ] + }, + { + "name": "apply", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "expression", + "type": "block", + "expression": true, + "token": "APPLY", + "arguments": [ + { + "name": "exists", + "token": "exists", + "type": "function", + "summary": "Checks whether a field exists in a document.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "log", + "token": "log", + "type": "function", + "summary": "Return the logarithm of a number, property or subexpression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "abs", + "token": "abs", + "type": "function", + "summary": "Return the absolute value of a numeric expression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "ceil", + "token": "ceil", + "type": "function", + "summary": "Round to the smallest integer not less than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "floor", + "token": "floor", + "type": "function", + "summary": "Round to largest integer not greater than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "log2", + "token": "log2", + "type": "function", + "summary": "Return the logarithm of x to base 2", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "exp", + "token": "exp", + "type": "function", + "summary": "Return the exponent of x, e.g., e^x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "sqrt", + "token": "sqrt", + "type": "function", + "summary": "Return the square root of x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "upper", + "token": "upper", + "type": "function", + "summary": "Return the uppercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "lower", + "token": "lower", + "type": "function", + "summary": "Return the lowercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "startswith", + "token": "startswith", + "type": "function", + "summary": "Return 1 if s2 is the prefix of s1, 0 otherwise.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "contains", + "token": "contains", + "type": "function", + "summary": "Return the number of occurrences of s2 in s1, 0 otherwise. If s2 is an empty string, return length(s1) + 1.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "strlen", + "token": "strlen", + "type": "function", + "summary": "Return the length of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "substr", + "token": "substr", + "type": "function", + "summary": "Return the substring of s, starting at offset and having count characters. If offset is negative, it represents the distance from the end of the string. If count is -1, it means \"the rest of the string starting at offset\".", + "arguments": [ + { + "token": "s" + }, + { + "token": "offset" + }, + { + "token": "count" + } + ] + }, + { + "name": "format", + "token": "format", + "type": "function", + "summary": "Use the arguments following fmt to format a string. Currently the only format argument supported is %s and it applies to all types of arguments.", + "arguments": [ + { + "token": "fmt" + } + ] + }, + { + "name": "matched_terms", + "token": "matched_terms", + "type": "function", + "summary": "Return the query terms that matched for each record (up to 100), as a list. If a limit is specified, Redis will return the first N matches found, based on query order.", + "arguments": [ + { + "token": "max_terms=100", + "optional": true + } + ] + }, + { + "name": "split", + "token": "split", + "type": "function", + "summary": "Split a string by any character in the string sep, and strip any characters in strip. If only s is specified, it is split by commas and spaces are stripped. The output is an array.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "timefmt", + "token": "timefmt", + "type": "function", + "summary": "Return a formatted time string based on a numeric timestamp value x.", + "arguments": [ + { + "token": "x" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "parsetime", + "token": "parsetime", + "type": "function", + "summary": "The opposite of timefmt() - parse a time format using a given format string", + "arguments": [ + { + "token": "timesharing" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "day", + "token": "day", + "type": "function", + "summary": "Round a Unix timestamp to midnight (00:00) start of the current day.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "hour", + "token": "hour", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current hour.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "minute", + "token": "minute", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current minute.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "month", + "token": "month", + "type": "function", + "summary": "Round a unix timestamp to the beginning of the current month.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofweek", + "token": "dayofweek", + "type": "function", + "summary": "Convert a Unix timestamp to the day number (Sunday = 0).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofmonth", + "token": "dayofmonth", + "type": "function", + "summary": "Convert a Unix timestamp to the day of month number (1 .. 31).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofyear", + "token": "dayofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the day of year number (0 .. 365).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "year", + "token": "year", + "type": "function", + "summary": "Convert a Unix timestamp to the current year (e.g. 2018).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "monthofyear", + "token": "monthofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the current month (0 .. 11).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "geodistance", + "token": "geodistance", + "type": "function", + "summary": "Return distance in meters.", + "arguments": [ + { + "token": "" + } + ] + } + ] + }, + { + "name": "name", + "type": "string", + "token": "AS" + } + ] + }, + { + "name": "limit", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "limit", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "num", + "type": "integer" + } + ] + }, + { + "name": "filter", + "type": "string", + "optional": true, + "expression": true, + "token": "FILTER", + "summary": "Applies a numeric range filter to restrict results to documents with field values within the specified range." + }, + { + "name": "cursor", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "withcursor", + "type": "pure-token", + "token": "WITHCURSOR" + }, + { + "name": "read_size", + "type": "integer", + "optional": true, + "token": "COUNT" + }, + { + "name": "idle_time", + "type": "integer", + "optional": true, + "token": "MAXIDLE" + } + ] + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "params", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3", + "summary": "Sets the query dialect version to be used." + } + ], + "since": "1.1.0", + "group": "search" + }, + "FT.ALIASADD": { + "summary": "Adds an alias to the index", + "complexity": "O(1)", + "arguments": [ + { + "name": "alias", + "type": "string" + }, + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.ALIASDEL": { + "summary": "Deletes an alias from the index", + "complexity": "O(1)", + "arguments": [ + { + "name": "alias", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.ALIASUPDATE": { + "summary": "Adds or updates an alias to the index", + "complexity": "O(1)", + "arguments": [ + { + "name": "alias", + "type": "string" + }, + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.ALTER": { + "summary": "Adds a new field to the index", + "complexity": "O(N) where N is the number of keys in the keyspace", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "skipinitialscan", + "type": "pure-token", + "token": "SKIPINITIALSCAN", + "optional": true, + "summary": "Skips the initial scan of the database when creating the index." + }, + { + "name": "schema", + "type": "pure-token", + "token": "SCHEMA", + "summary": "Defines the fields in the index and their properties, such as type (`TEXT`, `TAG`, `NUMERIC`, etc.)." + }, + { + "name": "add", + "type": "pure-token", + "token": "ADD" + }, + { + "name": "field", + "type": "string", + "summary": "Specifies a field in the index schema with its properties." + }, + { + "name": "options", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.CONFIG GET": { + "summary": "Retrieves runtime configuration options", + "complexity": "O(1)", + "deprecated_since": "8.0.0", + "replaced_by": "CONFIG GET", + "arguments": [ + { + "name": "option", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.CONFIG HELP": { + "summary": "Help description of runtime configuration options", + "complexity": "O(1)", + "deprecated_since": "8.0.0", + "replaced_by": "CONFIG HELP", + "arguments": [ + { + "name": "option", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.CONFIG SET": { + "summary": "Sets runtime configuration options", + "complexity": "O(1)", + "deprecated_since": "8.0.0", + "replaced_by": "CONFIG SET", + "arguments": [ + { + "name": "option", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.CREATE": { + "summary": "Creates an index with the given spec", + "complexity": "O(K) at creation where K is the number of fields, O(N) if scanning the keyspace is triggered, where N is the number of keys in the keyspace", + "history": [ + [ + "2.0.0", + "Added `PAYLOAD_FIELD` argument for backward support of `FT.SEARCH` deprecated `WITHPAYLOADS` argument" + ], + [ + "2.0.0", + "Deprecated `PAYLOAD_FIELD` argument" + ] + ], + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index." + }, + { + "name": "data_type", + "token": "ON", + "type": "oneof", + "arguments": [ + { + "name": "hash", + "type": "pure-token", + "token": "HASH" + }, + { + "name": "json", + "type": "pure-token", + "token": "JSON" + } + ], + "optional": true, + "summary": "Specifies the type of data to index, such as HASH or JSON." + }, + { + "name": "prefix", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "token": "PREFIX" + }, + { + "name": "prefix", + "type": "string", + "multiple": true, + "summary": "Filters indexed documents to include only keys that start with the specified prefix." + } + ], + "summary": "Filters indexed documents to include only keys that start with the specified prefix." + }, + { + "name": "filter", + "type": "string", + "optional": true, + "token": "FILTER", + "summary": "Applies a numeric range filter to restrict results to documents with field values within the specified range." + }, + { + "name": "default_lang", + "type": "string", + "token": "LANGUAGE", + "optional": true, + "summary": "Defines the default language for the index." + }, + { + "name": "lang_attribute", + "type": "string", + "token": "LANGUAGE_FIELD", + "optional": true, + "summary": "Specifies the attribute from which the language is determined." + }, + { + "name": "default_score", + "type": "double", + "token": "SCORE", + "optional": true, + "summary": "Sets the default score for documents in the index." + }, + { + "name": "score_attribute", + "type": "string", + "token": "SCORE_FIELD", + "optional": true, + "summary": "Specifies the attribute from which the score is derived." + }, + { + "name": "payload_attribute", + "type": "string", + "token": "PAYLOAD_FIELD", + "optional": true, + "summary": "Defines the attribute used for payloads in the index." + }, + { + "name": "maxtextfields", + "type": "pure-token", + "token": "MAXTEXTFIELDS", + "optional": true, + "summary": "Increases the maximum number of text fields allowed in the schema." + }, + { + "name": "seconds", + "type": "double", + "token": "TEMPORARY", + "optional": true, + "summary": "Specifies the duration (in seconds) for which the index remains active (temporary index)." + }, + { + "name": "nooffsets", + "type": "pure-token", + "token": "NOOFFSETS", + "optional": true, + "summary": "Disables storage of term offsets for index entries." + }, + { + "name": "nohl", + "type": "pure-token", + "token": "NOHL", + "optional": true, + "summary": "Disables support for highlighting in search results." + }, + { + "name": "nofields", + "type": "pure-token", + "token": "NOFIELDS", + "optional": true, + "summary": "Omits returning fields from search results." + }, + { + "name": "nofreqs", + "type": "pure-token", + "token": "NOFREQS", + "optional": true, + "summary": "Disables storage of term frequencies in the index." + }, + { + "name": "stopwords", + "type": "block", + "optional": true, + "token": "STOPWORDS", + "arguments": [ + { + "name": "count", + "type": "integer" + }, + { + "name": "stopword", + "type": "string", + "multiple": true, + "optional": true + } + ], + "summary": "Defines custom stop words for the index, which will be ignored during full-text search." + }, + { + "name": "skipinitialscan", + "type": "pure-token", + "token": "SKIPINITIALSCAN", + "optional": true, + "summary": "Skips the initial scan of the database when creating the index." + }, + { + "name": "schema", + "type": "pure-token", + "token": "SCHEMA", + "summary": "Defines the fields in the index and their properties, such as type (`TEXT`, `TAG`, `NUMERIC`, etc.)." + }, + { + "name": "field", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "field_name", + "type": "string" + }, + { + "name": "alias", + "type": "string", + "token": "AS", + "optional": true + }, + { + "name": "field_type", + "type": "oneof", + "arguments": [ + { + "name": "text", + "type": "pure-token", + "token": "TEXT" + }, + { + "name": "tag", + "type": "pure-token", + "token": "TAG" + }, + { + "name": "numeric", + "type": "pure-token", + "token": "NUMERIC" + }, + { + "name": "geo", + "type": "pure-token", + "token": "GEO" + }, + { + "name": "vector", + "type": "pure-token", + "token": "VECTOR" + } + ] + }, + { + "name": "withsuffixtrie", + "type": "pure-token", + "token": "WITHSUFFIXTRIE", + "optional": true + }, + { + "name": "INDEXEMPTY", + "type": "pure-token", + "token": "INDEXEMPTY", + "optional": true + }, + { + "name": "indexmissing", + "type": "pure-token", + "token": "INDEXMISSING", + "optional": true + }, + { + "name": "sortable", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "sortable", + "type": "pure-token", + "token": "SORTABLE" + }, + { + "name": "UNF", + "type": "pure-token", + "token": "UNF", + "optional": true + } + ] + }, + { + "name": "noindex", + "type": "pure-token", + "token": "NOINDEX", + "optional": true + } + ], + "summary": "Specifies a field in the index schema with its properties." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.CURSOR DEL": { + "summary": "Deletes a cursor", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "cursor_id", + "type": "integer" + } + ], + "command_tips": [ + "REQUEST_POLICY:SPECIAL" + ], + "since": "1.1.0", + "group": "search" + }, + "FT.CURSOR READ": { + "summary": "Reads from a cursor", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "cursor_id", + "type": "integer" + }, + { + "name": "read size", + "type": "integer", + "optional": true, + "token": "COUNT" + } + ], + "command_tips": [ + "REQUEST_POLICY:SPECIAL" + ], + "since": "1.1.0", + "group": "search" + }, + "FT.DICTADD": { + "summary": "Adds terms to a dictionary", + "complexity": "O(1)", + "arguments": [ + { + "name": "dict", + "type": "string" + }, + { + "name": "term", + "type": "string", + "multiple": true + } + ], + "since": "1.4.0", + "group": "search" + }, + "FT.DICTDEL": { + "summary": "Deletes terms from a dictionary", + "complexity": "O(1)", + "arguments": [ + { + "name": "dict", + "type": "string" + }, + { + "name": "term", + "type": "string", + "multiple": true + } + ], + "since": "1.4.0", + "group": "search" + }, + "FT.DICTDUMP": { + "summary": "Dumps all terms in the given dictionary", + "complexity": "O(N), where N is the size of the dictionary", + "arguments": [ + { + "name": "dict", + "type": "string" + } + ], + "since": "1.4.0", + "group": "search" + }, + "FT.DROPINDEX": { + "summary": "Deletes the index", + "complexity": "O(1) or O(N) if documents are deleted, where N is the number of keys in the keyspace", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "delete docs", + "type": "oneof", + "arguments": [ + { + "name": "delete docs", + "type": "pure-token", + "token": "DD" + } + ], + "optional": true + } + ], + "since": "2.0.0", + "group": "search" + }, + "FT.EXPLAIN": { + "summary": "Returns the execution plan for a complex query", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3", + "summary": "Sets the query dialect version to be used." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.EXPLAINCLI": { + "summary": "Returns the execution plan for a complex query", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3", + "summary": "Sets the query dialect version to be used." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.HYBRID": { + "summary": "Performs hybrid search combining text search and vector similarity search", + "complexity": "O(N+M) where N is the complexity of the text search and M is the complexity of the vector search", + "arguments": [ + { + "name": "index", + "type": "string" + }, + { + "name": "search_clause", + "type": "block", + "arguments": [ + { + "name": "search", + "type": "pure-token", + "token": "SEARCH" + }, + { + "name": "query", + "type": "string" + }, + { + "name": "scorer", + "type": "string", + "token": "SCORER", + "optional": true + }, + { + "name": "yield_score_as", + "type": "string", + "token": "YIELD_SCORE_AS", + "optional": true + } + ] + }, + { + "name": "vsim_clause", + "type": "block", + "arguments": [ + { + "name": "vsim", + "type": "pure-token", + "token": "VSIM" + }, + { + "name": "field", + "type": "string" + }, + { + "name": "vector", + "type": "string" + }, + { + "name": "vector_query_type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "knn_clause", + "type": "block", + "arguments": [ + { + "name": "knn", + "type": "pure-token", + "token": "KNN" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "k", + "type": "integer", + "token": "K" + }, + { + "name": "ef_runtime", + "type": "integer", + "token": "EF_RUNTIME", + "optional": true + }, + { + "name": "yield_score_as", + "type": "string", + "token": "YIELD_SCORE_AS", + "optional": true + } + ] + }, + { + "name": "range_clause", + "type": "block", + "arguments": [ + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "radius", + "type": "double", + "token": "RADIUS" + }, + { + "name": "epsilon", + "type": "double", + "token": "EPSILON", + "optional": true + }, + { + "name": "yield_score_as", + "type": "string", + "token": "YIELD_SCORE_AS", + "optional": true + } + ] + } + ] + }, + { + "name": "filter", + "type": "string", + "token": "FILTER", + "expression": true, + "optional": true + } + ] + }, + { + "name": "combine", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "combine", + "type": "pure-token", + "token": "COMBINE" + }, + { + "name": "method", + "type": "oneof", + "arguments": [ + { + "name": "rrf_method", + "type": "block", + "arguments": [ + { + "name": "rrf", + "type": "pure-token", + "token": "RRF" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "constant", + "type": "double", + "token": "CONSTANT", + "optional": true + }, + { + "name": "window", + "type": "integer", + "token": "WINDOW", + "optional": true + }, + { + "name": "yield_score_as", + "type": "string", + "token": "YIELD_SCORE_AS", + "optional": true + } + ] + }, + { + "name": "linear_method", + "type": "block", + "arguments": [ + { + "name": "linear", + "type": "pure-token", + "token": "LINEAR" + }, + { + "name": "count", + "type": "integer" + }, + { + "name": "weights", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "alpha", + "type": "double", + "token": "ALPHA" + }, + { + "name": "beta", + "type": "double", + "token": "BETA" + } + ] + }, + { + "name": "window", + "type": "integer", + "token": "WINDOW", + "optional": true + }, + { + "name": "yield_score_as", + "type": "string", + "token": "YIELD_SCORE_AS", + "optional": true + } + ] + } + ] + } + ] + }, + { + "name": "limit", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "limit", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "num", + "type": "integer" + } + ] + }, + { + "name": "sorting", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "sortby", + "type": "block", + "arguments": [ + { + "name": "sortby", + "type": "string", + "token": "SORTBY" + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "token": "DESC" + } + ] + } + ] + }, + { + "name": "nosort", + "type": "pure-token", + "token": "NOSORT" + } + ] + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "params", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + }, + { + "name": "format", + "type": "string", + "optional": true, + "token": "FORMAT" + }, + { + "name": "load", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "LOAD" + }, + { + "name": "field", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "loadall", + "type": "pure-token", + "token": "LOAD *", + "optional": true + }, + { + "name": "groupby", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "groupby", + "type": "pure-token", + "token": "GROUPBY" + }, + { + "name": "nproperties", + "type": "integer" + }, + { + "name": "property", + "type": "string", + "multiple": true + }, + { + "name": "reduce", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "reduce", + "type": "pure-token", + "token": "REDUCE" + }, + { + "name": "function", + "type": "oneof", + "arguments": [ + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "count_distinct", + "type": "pure-token", + "token": "COUNT_DISTINCT" + }, + { + "name": "count_distinctish", + "type": "pure-token", + "token": "COUNT_DISTINCTISH" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "stddev", + "type": "pure-token", + "token": "STDDEV" + }, + { + "name": "quantile", + "type": "pure-token", + "token": "QUANTILE" + }, + { + "name": "tolist", + "type": "pure-token", + "token": "TOLIST" + }, + { + "name": "first_value", + "type": "pure-token", + "token": "FIRST_VALUE" + }, + { + "name": "random_sample", + "type": "pure-token", + "token": "RANDOM_SAMPLE" + } + ] + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "arg", + "type": "string", + "multiple": true + }, + { + "name": "name", + "type": "string", + "token": "AS", + "optional": true + } + ] + } + ] + }, + { + "name": "apply", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "expression", + "type": "block", + "expression": true, + "token": "APPLY", + "arguments": [ + { + "name": "exists", + "token": "exists", + "type": "function", + "summary": "Checks whether a field exists in a document.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "log", + "token": "log", + "type": "function", + "summary": "Return the logarithm of a number, property or subexpression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "abs", + "token": "abs", + "type": "function", + "summary": "Return the absolute value of a numeric expression", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "ceil", + "token": "ceil", + "type": "function", + "summary": "Round to the smallest integer not less than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "floor", + "token": "floor", + "type": "function", + "summary": "Round to largest integer not greater than x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "log2", + "token": "log2", + "type": "function", + "summary": "Return the logarithm of x to base 2", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "exp", + "token": "exp", + "type": "function", + "summary": "Return the exponent of x, e.g., e^x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "sqrt", + "token": "sqrt", + "type": "function", + "summary": "Return the square root of x", + "arguments": [ + { + "token": "x" + } + ] + }, + { + "name": "upper", + "token": "upper", + "type": "function", + "summary": "Return the uppercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "lower", + "token": "lower", + "type": "function", + "summary": "Return the lowercase conversion of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "startswith", + "token": "startswith", + "type": "function", + "summary": "Return 1 if s2 is the prefix of s1, 0 otherwise.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "contains", + "token": "contains", + "type": "function", + "summary": "Return the number of occurrences of s2 in s1, 0 otherwise. If s2 is an empty string, return length(s1) + 1.", + "arguments": [ + { + "token": "s1" + }, + { + "token": "s2" + } + ] + }, + { + "name": "strlen", + "token": "strlen", + "type": "function", + "summary": "Return the length of s", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "substr", + "token": "substr", + "type": "function", + "summary": "Return the substring of s, starting at offset and having count characters. If offset is negative, it represents the distance from the end of the string. If count is -1, it means \"the rest of the string starting at offset\".", + "arguments": [ + { + "token": "s" + }, + { + "token": "offset" + }, + { + "token": "count" + } + ] + }, + { + "name": "format", + "token": "format", + "type": "function", + "summary": "Use the arguments following fmt to format a string. Currently the only format argument supported is %s and it applies to all types of arguments.", + "arguments": [ + { + "token": "fmt" + } + ] + }, + { + "name": "matched_terms", + "token": "matched_terms", + "type": "function", + "summary": "Return the query terms that matched for each record (up to 100), as a list. If a limit is specified, Redis will return the first N matches found, based on query order.", + "arguments": [ + { + "token": "max_terms=100", + "optional": true + } + ] + }, + { + "name": "split", + "token": "split", + "type": "function", + "summary": "Split a string by any character in the string sep, and strip any characters in strip. If only s is specified, it is split by commas and spaces are stripped. The output is an array.", + "arguments": [ + { + "token": "s" + } + ] + }, + { + "name": "timefmt", + "token": "timefmt", + "type": "function", + "summary": "Return a formatted time string based on a numeric timestamp value x.", + "arguments": [ + { + "token": "x" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "parsetime", + "token": "parsetime", + "type": "function", + "summary": "The opposite of timefmt() - parse a time format using a given format string", + "arguments": [ + { + "token": "timesharing" + }, + { + "token": "fmt", + "optional": true + } + ] + }, + { + "name": "day", + "token": "day", + "type": "function", + "summary": "Round a Unix timestamp to midnight (00:00) start of the current day.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "hour", + "token": "hour", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current hour.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "minute", + "token": "minute", + "type": "function", + "summary": "Round a Unix timestamp to the beginning of the current minute.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "month", + "token": "month", + "type": "function", + "summary": "Round a unix timestamp to the beginning of the current month.", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofweek", + "token": "dayofweek", + "type": "function", + "summary": "Convert a Unix timestamp to the day number (Sunday = 0).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofmonth", + "token": "dayofmonth", + "type": "function", + "summary": "Convert a Unix timestamp to the day of month number (1 .. 31).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "dayofyear", + "token": "dayofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the day of year number (0 .. 365).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "year", + "token": "year", + "type": "function", + "summary": "Convert a Unix timestamp to the current year (e.g. 2018).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "monthofyear", + "token": "monthofyear", + "type": "function", + "summary": "Convert a Unix timestamp to the current month (0 .. 11).", + "arguments": [ + { + "token": "timestamp" + } + ] + }, + { + "name": "geodistance", + "token": "geodistance", + "type": "function", + "summary": "Return distance in meters.", + "arguments": [ + { + "token": "" + } + ] + } + ] + }, + { + "name": "name", + "type": "string", + "token": "AS" + } + ] + }, + { + "name": "filter", + "type": "block", + "optional": true, + "token": "FILTER", + "arguments": [ + { + "name": "count", + "type": "integer" + }, + { + "name": "filter_expression", + "type": "string", + "expression": true + }, + { + "name": "policy", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "adhoc", + "type": "pure-token", + "token": "ADHOC" + }, + { + "name": "batches", + "type": "pure-token", + "token": "BATCHES" + } + ], + "token": "POLICY" + }, + { + "name": "batch_size_value", + "type": "integer", + "token": "BATCH_SIZE", + "optional": true + } + ] + } + ], + "since": "8.4.4", + "group": "search" + }, + "FT.INFO": { + "summary": "Returns information and statistics on the index", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.PROFILE": { + "summary": "Performs a `FT.SEARCH` or `FT.AGGREGATE` command and collects performance information", + "complexity": "O(N)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "querytype", + "type": "oneof", + "arguments": [ + { + "name": "search", + "type": "pure-token", + "token": "SEARCH" + }, + { + "name": "aggregate", + "type": "pure-token", + "token": "AGGREGATE" + } + ] + }, + { + "name": "limited", + "type": "pure-token", + "token": "LIMITED", + "optional": true, + "summary": "Restricts profiling to the initial phase of the query execution." + }, + { + "name": "queryword", + "type": "pure-token", + "token": "QUERY" + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + } + ], + "since": "2.2.0", + "group": "search" + }, + "FT.SEARCH": { + "summary": "Searches the index with a textual query, returning either documents or just ids", + "complexity": "O(N)", + "history": [ + [ + "2.0.0", + "Deprecated `WITHPAYLOADS` and `PAYLOAD` arguments" + ] + ], + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + }, + { + "name": "nocontent", + "type": "pure-token", + "token": "NOCONTENT", + "optional": true, + "summary": "Returns only the document IDs in the search results, excluding the content." + }, + { + "name": "verbatim", + "type": "pure-token", + "token": "VERBATIM", + "optional": true, + "summary": "Searches using the exact query terms without stemming or query expansion." + }, + { + "name": "nostopwords", + "type": "pure-token", + "token": "NOSTOPWORDS", + "optional": true + }, + { + "name": "withscores", + "type": "pure-token", + "token": "WITHSCORES", + "optional": true, + "summary": "Includes the relative scores of each document in the search results." + }, + { + "name": "withpayloads", + "type": "pure-token", + "token": "WITHPAYLOADS", + "optional": true + }, + { + "name": "withsortkeys", + "type": "pure-token", + "token": "WITHSORTKEYS", + "optional": true, + "summary": "Returns the sorting key value alongside the document ID." + }, + { + "name": "filter", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "numeric_field", + "type": "string", + "token": "FILTER" + }, + { + "name": "min", + "type": "double" + }, + { + "name": "max", + "type": "double" + } + ], + "summary": "Applies a numeric range filter to restrict results to documents with field values within the specified range." + }, + { + "name": "geo_filter", + "type": "block", + "optional": true, + "multiple": true, + "arguments": [ + { + "name": "geo_field", + "type": "string", + "token": "GEOFILTER" + }, + { + "name": "lon", + "type": "double" + }, + { + "name": "lat", + "type": "double" + }, + { + "name": "radius", + "type": "double" + }, + { + "name": "radius_type", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "token": "m" + }, + { + "name": "km", + "type": "pure-token", + "token": "km" + }, + { + "name": "mi", + "type": "pure-token", + "token": "mi" + }, + { + "name": "ft", + "type": "pure-token", + "token": "ft" + } + ] + } + ] + }, + { + "name": "in_keys", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "INKEYS" + }, + { + "name": "key", + "type": "string", + "multiple": true + } + ] + }, + { + "name": "in_fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "INFIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true, + "summary": "Specifies a field in the index schema with its properties." + } + ] + }, + { + "name": "return", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "RETURN" + }, + { + "name": "identifiers", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "identifier", + "type": "string" + }, + { + "name": "property", + "type": "string", + "token": "AS", + "optional": true + } + ] + } + ] + }, + { + "name": "summarize", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "summarize", + "type": "pure-token", + "token": "SUMMARIZE" + }, + { + "name": "fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "FIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true, + "summary": "Specifies a field in the index schema with its properties." + } + ] + }, + { + "name": "num", + "type": "integer", + "token": "FRAGS", + "optional": true + }, + { + "name": "fragsize", + "type": "integer", + "token": "LEN", + "optional": true + }, + { + "name": "separator", + "type": "string", + "token": "SEPARATOR", + "optional": true + } + ], + "summary": "Splits a field into contextual fragments surrounding the found terms, Note that summarize for JSON documents is not currently supported." + }, + { + "name": "highlight", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "highlight", + "type": "pure-token", + "token": "HIGHLIGHT", + "summary": "Highlights terms in the search results, with customizable tags for emphasis." + }, + { + "name": "fields", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "string", + "token": "FIELDS" + }, + { + "name": "field", + "type": "string", + "multiple": true, + "summary": "Specifies a field in the index schema with its properties." + } + ] + }, + { + "name": "tags", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "tags", + "type": "pure-token", + "token": "TAGS" + }, + { + "name": "open", + "type": "string" + }, + { + "name": "close", + "type": "string" + } + ] + } + ], + "summary": "Highlights terms in the search results, with customizable tags for emphasis. Note that highlight for JSON documents is not currently supported." + }, + { + "name": "slop", + "type": "integer", + "optional": true, + "token": "SLOP" + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT", + "summary": "Sets a time limit for query execution, specified in milliseconds." + }, + { + "name": "inorder", + "type": "pure-token", + "token": "INORDER", + "optional": true + }, + { + "name": "language", + "type": "string", + "optional": true, + "token": "LANGUAGE", + "summary": "Specifies the default language for full-text search, influencing stemming and stop-word behavior." + }, + { + "name": "expander", + "type": "string", + "optional": true, + "token": "EXPANDER" + }, + { + "name": "scorer", + "type": "string", + "optional": true, + "token": "SCORER" + }, + { + "name": "explainscore", + "type": "pure-token", + "token": "EXPLAINSCORE", + "optional": true + }, + { + "name": "payload", + "type": "string", + "optional": true, + "token": "PAYLOAD" + }, + { + "name": "sortby", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "sortby", + "type": "string", + "token": "SORTBY" + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "token": "DESC" + } + ] + } + ] + }, + { + "name": "limit", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "limit", + "type": "pure-token", + "token": "LIMIT" + }, + { + "name": "offset", + "type": "integer" + }, + { + "name": "num", + "type": "integer" + } + ] + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "params", + "type": "pure-token", + "token": "PARAMS" + }, + { + "name": "nargs", + "type": "integer" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ] + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3", + "summary": "Sets the query dialect version to be used." + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT.SPELLCHECK": { + "summary": "Performs spelling correction on a query, returning suggestions for misspelled terms", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "query", + "type": "string", + "summary": "Specifies the query to profile and analyze performance." + }, + { + "name": "distance", + "token": "DISTANCE", + "type": "integer", + "optional": true + }, + { + "name": "terms", + "token": "TERMS", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "inclusion", + "type": "oneof", + "arguments": [ + { + "name": "include", + "type": "pure-token", + "token": "INCLUDE" + }, + { + "name": "exclude", + "type": "pure-token", + "token": "EXCLUDE" + } + ] + }, + { + "name": "dictionary", + "type": "string" + }, + { + "name": "terms", + "type": "string", + "multiple": true, + "optional": true + } + ] + }, + { + "name": "dialect", + "type": "integer", + "optional": true, + "token": "DIALECT", + "since": "2.4.3", + "summary": "Sets the query dialect version to be used." + } + ], + "since": "1.4.0", + "group": "search" + }, + "FT.SUGADD": { + "summary": "Adds a suggestion string to an auto-complete suggestion dictionary", + "complexity": "O(1)", + "history": [ + [ + "2.0.0", + "Deprecated `PAYLOAD` argument" + ] + ], + "arguments": [ + { + "name": "key", + "type": "string" + }, + { + "name": "string", + "type": "string" + }, + { + "name": "score", + "type": "double" + }, + { + "name": "increment score", + "type": "oneof", + "arguments": [ + { + "name": "incr", + "type": "pure-token", + "token": "INCR" + } + ], + "optional": true + }, + { + "name": "payload", + "token": "PAYLOAD", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "suggestion" + }, + "FT.SUGDEL": { + "summary": "Deletes a string from a suggestion index", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "string" + }, + { + "name": "string", + "type": "string" + } + ], + "since": "1.0.0", + "group": "suggestion" + }, + "FT.SUGGET": { + "summary": "Gets completion suggestions for a prefix", + "complexity": "O(1)", + "history": [ + [ + "2.0.0", + "Deprecated `WITHPAYLOADS` argument" + ] + ], + "arguments": [ + { + "name": "key", + "type": "string" + }, + { + "name": "prefix", + "type": "string", + "summary": "Filters indexed documents to include only keys that start with the specified prefix." + }, + { + "name": "fuzzy", + "type": "pure-token", + "token": "FUZZY", + "optional": true + }, + { + "name": "withscores", + "type": "pure-token", + "token": "WITHSCORES", + "optional": true, + "summary": "Includes the relative scores of each document in the search results." + }, + { + "name": "withpayloads", + "type": "pure-token", + "token": "WITHPAYLOADS", + "optional": true + }, + { + "name": "max", + "token": "MAX", + "type": "integer", + "optional": true + } + ], + "since": "1.0.0", + "group": "suggestion" + }, + "FT.SUGLEN": { + "summary": "Gets the size of an auto-complete suggestion dictionary", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "string" + } + ], + "since": "1.0.0", + "group": "suggestion" + }, + "FT.SYNDUMP": { + "summary": "Dumps the contents of a synonym group", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + } + ], + "since": "1.2.0", + "group": "search" + }, + "FT.SYNUPDATE": { + "summary": "Creates or updates a synonym group with additional terms", + "complexity": "O(1)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "synonym_group_id", + "type": "string" + }, + { + "name": "skipinitialscan", + "type": "pure-token", + "token": "SKIPINITIALSCAN", + "optional": true, + "summary": "Skips the initial scan of the database when creating the index." + }, + { + "name": "term", + "type": "string", + "multiple": true + } + ], + "since": "1.2.0", + "group": "search" + }, + "FT.TAGVALS": { + "summary": "Returns the distinct tags indexed in a Tag field", + "complexity": "O(N)", + "arguments": [ + { + "name": "index", + "type": "string", + "summary": "Specifies the name of the index. The index must be created using `FT.CREATE`." + }, + { + "name": "field_name", + "type": "string" + } + ], + "since": "1.0.0", + "group": "search" + }, + "FT._LIST": { + "summary": "Returns a list of all existing indexes", + "complexity": "O(1)", + "since": "2.0.0", + "group": "search" + }, + "FUNCTION": { + "summary": "A container for function commands.", + "since": "7.0.0", + "group": "scripting", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "FUNCTION DELETE": { + "summary": "Deletes a library and its functions.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@slow", + "@scripting" + ], + "arity": 3, + "arguments": [ + { + "name": "library-name", + "type": "string", + "display_text": "library-name" + } + ], + "command_flags": [ + "write", + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FUNCTION DUMP": { + "summary": "Dumps all libraries into a serialized binary payload.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(N) where N is the number of functions", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "noscript" + ] + }, + "FUNCTION FLUSH": { + "summary": "Deletes all libraries and functions.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(N) where N is the number of functions deleted", + "acl_categories": [ + "@write", + "@slow", + "@scripting" + ], + "arity": -2, + "arguments": [ + { + "name": "flush-type", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "async", + "type": "pure-token", + "display_text": "async", + "token": "ASYNC" + }, + { + "name": "sync", + "type": "pure-token", + "display_text": "sync", + "token": "SYNC" + } + ] + } + ], + "command_flags": [ + "write", + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FUNCTION HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "FUNCTION KILL": { + "summary": "Terminates a function during execution.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "noscript", + "allow_busy" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:one_succeeded" + ] + }, + "FUNCTION LIST": { + "summary": "Returns information about all libraries.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(N) where N is the number of functions", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -2, + "arguments": [ + { + "name": "library-name-pattern", + "type": "string", + "display_text": "library-name-pattern", + "token": "LIBRARYNAME", + "optional": true + }, + { + "name": "withcode", + "type": "pure-token", + "display_text": "withcode", + "token": "WITHCODE", + "optional": true + } + ], + "command_flags": [ + "noscript" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "FUNCTION LOAD": { + "summary": "Creates a library.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(1) (considering compilation time is redundant)", + "acl_categories": [ + "@write", + "@slow", + "@scripting" + ], + "arity": -3, + "arguments": [ + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE", + "optional": true + }, + { + "name": "function-code", + "type": "string", + "display_text": "function-code" + } + ], + "command_flags": [ + "write", + "denyoom", + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FUNCTION RESTORE": { + "summary": "Restores all libraries from a payload.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(N) where N is the number of functions on the payload", + "acl_categories": [ + "@write", + "@slow", + "@scripting" + ], + "arity": -3, + "arguments": [ + { + "name": "serialized-value", + "type": "string", + "display_text": "serialized-value" + }, + { + "name": "policy", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "flush", + "type": "pure-token", + "display_text": "flush", + "token": "FLUSH" + }, + { + "name": "append", + "type": "pure-token", + "display_text": "append", + "token": "APPEND" + }, + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "FUNCTION STATS": { + "summary": "Returns information about a function during execution.", + "since": "7.0.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "noscript", + "allow_busy" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_shards", + "response_policy:special" + ] + }, + "GEOADD": { + "summary": "Adds one or more members to a geospatial index. The key is created if it doesn't exist.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(log(N)) for each item added, where N is the number of elements in the sorted set.", + "history": [ + [ + "6.2.0", + "Added the `CH`, `NX` and `XX` options." + ] + ], + "acl_categories": [ + "@write", + "@geo", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "condition", + "type": "oneof", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + } + ] + }, + { + "name": "change", + "type": "pure-token", + "display_text": "change", + "token": "CH", + "since": "6.2.0", + "optional": true + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "longitude", + "type": "double", + "display_text": "longitude" + }, + { + "name": "latitude", + "type": "double", + "display_text": "latitude" + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "GEODIST": { + "summary": "Returns the distance between two members of a geospatial index.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member1", + "type": "string", + "display_text": "member1" + }, + { + "name": "member2", + "type": "string", + "display_text": "member2" + }, + { + "name": "unit", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + } + ], + "command_flags": [ + "readonly" + ] + }, + "GEOHASH": { + "summary": "Returns members from a geospatial index as geohash strings.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(1) for each member requested.", + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "GEOPOS": { + "summary": "Returns the longitude and latitude of members from a geospatial index.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(1) for each member requested.", + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "GEORADIUS": { + "summary": "Queries a geospatial index for members within a distance from a coordinate, optionally stores the result.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "deprecated_since": "6.2.0", + "replaced_by": "`GEOSEARCH` and `GEOSEARCHSTORE` with the `BYRADIUS` argument", + "history": [ + [ + "6.2.0", + "Added the `ANY` option for `COUNT`." + ], + [ + "7.0.0", + "Added support for uppercase unit names." + ] + ], + "acl_categories": [ + "@write", + "@geo", + "@slow" + ], + "arity": -6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + }, + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STORE", + "startfrom": 6 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STOREDIST", + "startfrom": 6 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "longitude", + "type": "double", + "display_text": "longitude" + }, + { + "name": "latitude", + "type": "double", + "display_text": "latitude" + }, + { + "name": "radius", + "type": "double", + "display_text": "radius" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + }, + { + "name": "withcoord", + "type": "pure-token", + "display_text": "withcoord", + "token": "WITHCOORD", + "optional": true + }, + { + "name": "withdist", + "type": "pure-token", + "display_text": "withdist", + "token": "WITHDIST", + "optional": true + }, + { + "name": "withhash", + "type": "pure-token", + "display_text": "withhash", + "token": "WITHHASH", + "optional": true + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "since": "6.2.0", + "optional": true + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "store", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "storekey", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "token": "STORE" + }, + { + "name": "storedistkey", + "type": "key", + "display_text": "key", + "key_spec_index": 2, + "token": "STOREDIST" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ], + "doc_flags": [ + "deprecated" + ] + }, + "GEORADIUSBYMEMBER": { + "summary": "Queries a geospatial index for members within a distance from a member, optionally stores the result.", + "since": "3.2.0", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "deprecated_since": "6.2.0", + "replaced_by": "`GEOSEARCH` and `GEOSEARCHSTORE` with the `BYRADIUS` and `FROMMEMBER` arguments", + "history": [ + [ + "7.0.0", + "Added support for uppercase unit names." + ] + ], + "acl_categories": [ + "@write", + "@geo", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + }, + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STORE", + "startfrom": 5 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STOREDIST", + "startfrom": 5 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + }, + { + "name": "radius", + "type": "double", + "display_text": "radius" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + }, + { + "name": "withcoord", + "type": "pure-token", + "display_text": "withcoord", + "token": "WITHCOORD", + "optional": true + }, + { + "name": "withdist", + "type": "pure-token", + "display_text": "withdist", + "token": "WITHDIST", + "optional": true + }, + { + "name": "withhash", + "type": "pure-token", + "display_text": "withhash", + "token": "WITHHASH", + "optional": true + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "optional": true + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "store", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "storekey", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "token": "STORE" + }, + { + "name": "storedistkey", + "type": "key", + "display_text": "key", + "key_spec_index": 2, + "token": "STOREDIST" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ], + "doc_flags": [ + "deprecated" + ] + }, + "GEORADIUSBYMEMBER_RO": { + "summary": "Returns members from a geospatial index that are within a distance from a member.", + "since": "3.2.10", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "deprecated_since": "6.2.0", + "replaced_by": "`GEOSEARCH` with the `BYRADIUS` and `FROMMEMBER` arguments", + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + }, + { + "name": "radius", + "type": "double", + "display_text": "radius" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + }, + { + "name": "withcoord", + "type": "pure-token", + "display_text": "withcoord", + "token": "WITHCOORD", + "optional": true + }, + { + "name": "withdist", + "type": "pure-token", + "display_text": "withdist", + "token": "WITHDIST", + "optional": true + }, + { + "name": "withhash", + "type": "pure-token", + "display_text": "withhash", + "token": "WITHHASH", + "optional": true + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "optional": true + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "GEORADIUS_RO": { + "summary": "Returns members from a geospatial index that are within a distance from a coordinate.", + "since": "3.2.10", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements inside the bounding box of the circular area delimited by center and radius and M is the number of items inside the index.", + "deprecated_since": "6.2.0", + "replaced_by": "`GEOSEARCH` with the `BYRADIUS` argument", + "history": [ + [ + "6.2.0", + "Added the `ANY` option for `COUNT`." + ] + ], + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "longitude", + "type": "double", + "display_text": "longitude" + }, + { + "name": "latitude", + "type": "double", + "display_text": "latitude" + }, + { + "name": "radius", + "type": "double", + "display_text": "radius" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + }, + { + "name": "withcoord", + "type": "pure-token", + "display_text": "withcoord", + "token": "WITHCOORD", + "optional": true + }, + { + "name": "withdist", + "type": "pure-token", + "display_text": "withdist", + "token": "WITHDIST", + "optional": true + }, + { + "name": "withhash", + "type": "pure-token", + "display_text": "withhash", + "token": "WITHHASH", + "optional": true + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "since": "6.2.0", + "optional": true + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "GEOSEARCH": { + "summary": "Queries a geospatial index for members inside an area of a box or a circle.", + "since": "6.2.0", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements in the grid-aligned bounding box area around the shape provided as the filter and M is the number of items inside the shape", + "history": [ + [ + "7.0.0", + "Added support for uppercase unit names." + ] + ], + "acl_categories": [ + "@read", + "@geo", + "@slow" + ], + "arity": -7, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "from", + "type": "oneof", + "arguments": [ + { + "name": "member", + "type": "string", + "display_text": "member", + "token": "FROMMEMBER" + }, + { + "name": "fromlonlat", + "type": "block", + "token": "FROMLONLAT", + "arguments": [ + { + "name": "longitude", + "type": "double", + "display_text": "longitude" + }, + { + "name": "latitude", + "type": "double", + "display_text": "latitude" + } + ] + } + ] + }, + { + "name": "by", + "type": "oneof", + "arguments": [ + { + "name": "circle", + "type": "block", + "arguments": [ + { + "name": "radius", + "type": "double", + "display_text": "radius", + "token": "BYRADIUS" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + } + ] + }, + { + "name": "box", + "type": "block", + "arguments": [ + { + "name": "width", + "type": "double", + "display_text": "width", + "token": "BYBOX" + }, + { + "name": "height", + "type": "double", + "display_text": "height" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + } + ] + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "optional": true + } + ] + }, + { + "name": "withcoord", + "type": "pure-token", + "display_text": "withcoord", + "token": "WITHCOORD", + "optional": true + }, + { + "name": "withdist", + "type": "pure-token", + "display_text": "withdist", + "token": "WITHDIST", + "optional": true + }, + { + "name": "withhash", + "type": "pure-token", + "display_text": "withhash", + "token": "WITHHASH", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "GEOSEARCHSTORE": { + "summary": "Queries a geospatial index for members inside an area of a box or a circle, optionally stores the result.", + "since": "6.2.0", + "group": "geo", + "complexity": "O(N+log(M)) where N is the number of elements in the grid-aligned bounding box area around the shape provided as the filter and M is the number of items inside the shape", + "history": [ + [ + "7.0.0", + "Added support for uppercase unit names." + ] + ], + "acl_categories": [ + "@write", + "@geo", + "@slow" + ], + "arity": -8, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 1 + }, + { + "name": "from", + "type": "oneof", + "arguments": [ + { + "name": "member", + "type": "string", + "display_text": "member", + "token": "FROMMEMBER" + }, + { + "name": "fromlonlat", + "type": "block", + "token": "FROMLONLAT", + "arguments": [ + { + "name": "longitude", + "type": "double", + "display_text": "longitude" + }, + { + "name": "latitude", + "type": "double", + "display_text": "latitude" + } + ] + } + ] + }, + { + "name": "by", + "type": "oneof", + "arguments": [ + { + "name": "circle", + "type": "block", + "arguments": [ + { + "name": "radius", + "type": "double", + "display_text": "radius", + "token": "BYRADIUS" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + } + ] + }, + { + "name": "box", + "type": "block", + "arguments": [ + { + "name": "width", + "type": "double", + "display_text": "width", + "token": "BYBOX" + }, + { + "name": "height", + "type": "double", + "display_text": "height" + }, + { + "name": "unit", + "type": "oneof", + "arguments": [ + { + "name": "m", + "type": "pure-token", + "display_text": "m", + "token": "M" + }, + { + "name": "km", + "type": "pure-token", + "display_text": "km", + "token": "KM" + }, + { + "name": "ft", + "type": "pure-token", + "display_text": "ft", + "token": "FT" + }, + { + "name": "mi", + "type": "pure-token", + "display_text": "mi", + "token": "MI" + } + ] + } + ] + } + ] + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "count-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT" + }, + { + "name": "any", + "type": "pure-token", + "display_text": "any", + "token": "ANY", + "optional": true + } + ] + }, + { + "name": "storedist", + "type": "pure-token", + "display_text": "storedist", + "token": "STOREDIST", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "GET": { + "summary": "Returns the string value of a key.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@string", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "GETBIT": { + "summary": "Returns a bit value by offset.", + "since": "2.2.0", + "group": "bitmap", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@bitmap", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "GETDEL": { + "summary": "Returns the string value of a key after deleting the key.", + "since": "6.2.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "GETEX": { + "summary": "Returns the string value of a key after setting its expiration time.", + "since": "6.2.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "notes": "RW and UPDATE because it changes the TTL", + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "expiration", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "seconds", + "type": "integer", + "display_text": "seconds", + "token": "EX" + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds", + "token": "PX" + }, + { + "name": "unix-time-seconds", + "type": "unix-time", + "display_text": "unix-time-seconds", + "token": "EXAT" + }, + { + "name": "unix-time-milliseconds", + "type": "unix-time", + "display_text": "unix-time-milliseconds", + "token": "PXAT" + }, + { + "name": "persist", + "type": "pure-token", + "display_text": "persist", + "token": "PERSIST" + } + ] + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "GETRANGE": { + "summary": "Returns a substring of the string stored at a key.", + "since": "2.4.0", + "group": "string", + "complexity": "O(N) where N is the length of the returned string. The complexity is ultimately determined by the returned length, but because creating a substring from an existing string is very cheap, it can be considered O(1) for small strings.", + "acl_categories": [ + "@read", + "@string", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "end", + "type": "integer", + "display_text": "end" + } + ], + "command_flags": [ + "readonly" + ] + }, + "GETSET": { + "summary": "Returns the previous string value of a key after setting it to a new value.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "deprecated_since": "6.2.0", + "replaced_by": "`SET` with the `!GET` argument", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ], + "doc_flags": [ + "deprecated" + ] + }, + "GRAPH.CONFIG GET": { + "summary": "Retrieves a RedisGraph configuration", + "arguments": [ + { + "name": "name", + "type": "string" + } + ], + "since": "2.2.11", + "group": "graph" + }, + "GRAPH.CONFIG SET": { + "summary": "Updates a RedisGraph configuration", + "arguments": [ + { + "name": "name", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.2.11", + "group": "graph" + }, + "GRAPH.CONSTRAINT CREATE": { + "summary": "Creates a constraint on specified graph", + "since": "2.12.0", + "group": "graph" + }, + "GRAPH.CONSTRAINT DROP": { + "summary": "Deletes a constraint from specified graph", + "since": "2.12.0", + "group": "graph" + }, + "GRAPH.DELETE": { + "summary": "Completely removes the graph and all of its entities", + "arguments": [ + { + "name": "graph", + "type": "key" + } + ], + "since": "1.0.0", + "group": "graph" + }, + "GRAPH.EXPLAIN": { + "summary": "Returns a query execution plan without running the query", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string", + "dsl": "cypher" + } + ], + "since": "2.0.0", + "group": "graph" + }, + "GRAPH.LIST": { + "summary": "Lists all graph keys in the keyspace", + "since": "2.4.3", + "group": "graph" + }, + "GRAPH.PROFILE": { + "summary": "Executes a query and returns an execution plan augmented with metrics for each operation's execution", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string", + "dsl": "cypher" + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + } + ], + "since": "2.0.0", + "group": "graph" + }, + "GRAPH.QUERY": { + "summary": "Executes the given query against a specified graph", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string", + "dsl": "cypher" + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + } + ], + "since": "1.0.0", + "group": "graph" + }, + "GRAPH.RO_QUERY": { + "summary": "Executes a given read only query against a specified graph", + "arguments": [ + { + "name": "graph", + "type": "key" + }, + { + "name": "query", + "type": "string", + "dsl": "cypher" + }, + { + "name": "timeout", + "type": "integer", + "optional": true, + "token": "TIMEOUT" + } + ], + "since": "2.2.8", + "group": "graph" + }, + "GRAPH.SLOWLOG": { + "summary": "Returns a list containing up to 10 of the slowest queries issued against the given graph", + "arguments": [ + { + "name": "graph", + "type": "key" + } + ], + "since": "2.0.12", + "group": "graph" + }, + "HDEL": { + "summary": "Deletes one or more fields and their values from a hash. Deletes the hash if no fields remain.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the number of fields to be removed.", + "history": [ + [ + "2.4.0", + "Accepts multiple `field` arguments." + ] + ], + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field", + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "HELLO": { + "summary": "Handshakes with the Redis server.", + "since": "6.0.0", + "group": "connection", + "complexity": "O(1)", + "history": [ + [ + "6.2.0", + "`protover` made optional; when called without arguments the command reports the current connection's context." + ] + ], + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": -1, + "arguments": [ + { + "name": "arguments", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "protover", + "type": "integer", + "display_text": "protover" + }, + { + "name": "auth", + "type": "block", + "token": "AUTH", + "optional": true, + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username" + }, + { + "name": "password", + "type": "string", + "display_text": "password" + } + ] + }, + { + "name": "clientname", + "type": "string", + "display_text": "clientname", + "token": "SETNAME", + "optional": true + } + ] + } + ], + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "HEXISTS": { + "summary": "Determines whether a field exists in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@hash", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "HGET": { + "summary": "Returns the value of a field in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@hash", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "HGETALL": { + "summary": "Returns all fields and values in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the size of the hash.", + "acl_categories": [ + "@read", + "@hash", + "@slow" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "HINCRBY": { + "summary": "Increments the integer value of a field in a hash by a number. Uses 0 as initial value if the field doesn't exist.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "increment", + "type": "integer", + "display_text": "increment" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "HINCRBYFLOAT": { + "summary": "Increments the floating point value of a field by a number. Uses 0 as initial value if the field doesn't exist.", + "since": "2.6.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "increment", + "type": "double", + "display_text": "increment" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "HKEYS": { + "summary": "Returns all fields in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the size of the hash.", + "acl_categories": [ + "@read", + "@hash", + "@slow" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "HLEN": { + "summary": "Returns the number of fields in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@hash", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "HMGET": { + "summary": "Returns the values of all fields in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the number of fields being requested.", + "acl_categories": [ + "@read", + "@hash", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field", + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "HMSET": { + "summary": "Sets the values of multiple fields.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the number of fields being set.", + "deprecated_since": "4.0.0", + "replaced_by": "`HSET` with multiple field-value pairs", + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ], + "doc_flags": [ + "deprecated" + ] + }, + "HRANDFIELD": { + "summary": "Returns one or more random fields from a hash.", + "since": "6.2.0", + "group": "hash", + "complexity": "O(N) where N is the number of fields returned", + "acl_categories": [ + "@read", + "@hash", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "options", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count" + }, + { + "name": "withvalues", + "type": "pure-token", + "display_text": "withvalues", + "token": "WITHVALUES", + "optional": true + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "HSCAN": { + "summary": "Iterates over fields and values of a hash.", + "since": "2.8.0", + "group": "hash", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.", + "acl_categories": [ + "@read", + "@hash", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "cursor", + "type": "integer", + "display_text": "cursor" + }, + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "token": "MATCH", + "optional": true + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "HSET": { + "summary": "Creates or modifies the value of a field in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1) for each field/value pair added, so O(N) to add N field/value pairs when the command is called with multiple field/value pairs.", + "history": [ + [ + "4.0.0", + "Accepts multiple `field` and `value` arguments." + ] + ], + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "HSETNX": { + "summary": "Sets the value of a field in a hash only when the field doesn't exist.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@hash", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "HSTRLEN": { + "summary": "Returns the length of the value of a field.", + "since": "3.2.0", + "group": "hash", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@hash", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "field", + "type": "string", + "display_text": "field" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "HVALS": { + "summary": "Returns all values in a hash.", + "since": "2.0.0", + "group": "hash", + "complexity": "O(N) where N is the size of the hash.", + "acl_categories": [ + "@read", + "@hash", + "@slow" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "INCR": { + "summary": "Increments the integer value of a key by one. Uses 0 as initial value if the key doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "INCRBY": { + "summary": "Increments the integer value of a key by a number. Uses 0 as initial value if the key doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "increment", + "type": "integer", + "display_text": "increment" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "INCRBYFLOAT": { + "summary": "Increment the floating point value of a key by a number. Uses 0 as initial value if the key doesn't exist.", + "since": "2.6.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "increment", + "type": "double", + "display_text": "increment" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "INFO": { + "summary": "Returns information and statistics about the server.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added support for taking multiple section arguments." + ] + ], + "acl_categories": [ + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "section", + "type": "string", + "display_text": "section", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_shards", + "response_policy:special" + ] + }, + "JSON.ARRAPPEND": { + "summary": "Append one or more json values into the array at path after the last element in it.", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + }, + { + "name": "value", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.ARRINDEX": { + "summary": "Returns the index of the first occurrence of a JSON scalar value in the array at path", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the array, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "string" + }, + { + "name": "range", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer", + "optional": true + } + ] + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.ARRINSERT": { + "summary": "Inserts the JSON scalar(s) value at the specified index in the array at path", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the array, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "index", + "type": "integer" + }, + { + "name": "value", + "type": "string", + "multiple": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.ARRLEN": { + "summary": "Returns the length of the array at path", + "complexity": "O(1) where path is evaluated to a single value, O(N) where path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.ARRPOP": { + "summary": "Removes and returns the element at the specified index in the array at path", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the array and the specified index is not the last element, O(1) when path is evaluated to a single value and the specified index is the last element, or O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "path", + "type": "string" + }, + { + "name": "index", + "type": "integer", + "optional": true + } + ] + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.ARRTRIM": { + "summary": "Trims the array at path to contain only the specified inclusive range of indices from start to stop", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the array, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "start", + "type": "integer" + }, + { + "name": "stop", + "type": "integer" + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.CLEAR": { + "summary": "Clears all values from an array or an object and sets numeric values to `0`", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the values, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "2.0.0", + "group": "json" + }, + "JSON.DEBUG": { + "summary": "Debugging container command", + "complexity": "N/A", + "since": "1.0.0", + "group": "json" + }, + "JSON.DEBUG HELP": { + "summary": "Shows helpful information", + "complexity": "N/A", + "since": "1.0.0", + "group": "json" + }, + "JSON.DEBUG MEMORY": { + "summary": "Reports the size in bytes of a key", + "complexity": "O(N) when path is evaluated to a single value, where N is the size of the value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.DEL": { + "summary": "Deletes a value", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the deleted value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.FORGET": { + "summary": "Deletes a value", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the deleted value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.GET": { + "summary": "Gets the value at one or more paths in JSON serialized form", + "complexity": "O(N) when path is evaluated to a single value where N is the size of the value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "indent", + "token": "INDENT", + "type": "string", + "optional": true + }, + { + "name": "newline", + "token": "NEWLINE", + "type": "string", + "optional": true + }, + { + "name": "space", + "token": "SPACE", + "type": "string", + "optional": true + }, + { + "name": "path", + "type": "string", + "optional": true, + "multiple": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.MERGE": { + "summary": "Merges a given JSON value into matching paths. Consequently, JSON values at matching paths are updated, deleted, or expanded with new children", + "complexity": "O(M+N) when path is evaluated to a single value where M is the size of the original value (if it exists) and N is the size of the new value, O(M+N) when path is evaluated to multiple values where M is the size of the key and N is the size of the new value * the number of original values in the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ], + "since": "2.6.0", + "group": "json" + }, + "JSON.MGET": { + "summary": "Returns the values at a path from one or more keys", + "complexity": "O(M*N) when path is evaluated to a single value where M is the number of keys and N is the size of the value, O(N1+N2+...+Nm) when path is evaluated to multiple values where m is the number of keys and Ni is the size of the i-th key", + "arguments": [ + { + "name": "key", + "type": "key", + "multiple": true + }, + { + "name": "path", + "type": "string" + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.MSET": { + "summary": "Sets or updates the JSON value of one or more keys", + "complexity": "O(K*(M+N)) where k is the number of keys in the command, when path is evaluated to a single value where M is the size of the original value (if it exists) and N is the size of the new value, or O(K*(M+N)) when path is evaluated to multiple values where M is the size of the key and N is the size of the new value * the number of original values in the key", + "arguments": [ + { + "name": "triplet", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "string" + } + ] + } + ], + "since": "2.6.0", + "group": "json" + }, + "JSON.NUMINCRBY": { + "summary": "Increments the numeric value at path by a value", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "double" + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.NUMMULTBY": { + "summary": "Multiplies the numeric value at path by a value", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "deprecated_since": "2.0", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "double" + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.OBJKEYS": { + "summary": "Returns the JSON keys of the object at path", + "complexity": "O(N) when path is evaluated to a single value, where N is the number of keys in the object, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.OBJLEN": { + "summary": "Returns the number of keys of the object at path", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.RESP": { + "summary": "Returns the JSON value at path in Redis Serialization Protocol (RESP)", + "complexity": "O(N) when path is evaluated to a single value, where N is the size of the value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "deprecated_since": "2.6", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.SET": { + "summary": "Sets or updates the JSON value at a path", + "complexity": "O(M+N) when path is evaluated to a single value where M is the size of the original value (if it exists) and N is the size of the new value, O(M+N) when path is evaluated to multiple values where M is the size of the key and N is the size of the new value * the number of original values in the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + }, + { + "name": "value", + "type": "string" + }, + { + "name": "condition", + "type": "oneof", + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "token": "XX" + } + ], + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.STRAPPEND": { + "summary": "Appends a string to a JSON string value at path", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + }, + { + "name": "value", + "type": "string" + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.STRLEN": { + "summary": "Returns the length of the JSON String at path in key", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "JSON.TOGGLE": { + "summary": "Toggles a boolean value", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string" + } + ], + "since": "2.0.0", + "group": "json" + }, + "JSON.TYPE": { + "summary": "Returns the type of the JSON value at path", + "complexity": "O(1) when path is evaluated to a single value, O(N) when path is evaluated to multiple values, where N is the size of the key", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "path", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "json" + }, + "KEYS": { + "summary": "Returns all key names that match a pattern.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(N) with N being the number of keys in the database, under the assumption that the key names in the database and the given pattern have limited length.", + "acl_categories": [ + "@keyspace", + "@read", + "@slow", + "@dangerous" + ], + "arity": 2, + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern" + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "request_policy:all_shards", + "nondeterministic_output_order" + ] + }, + "LASTSAVE": { + "summary": "Returns the Unix timestamp of the last successful save to disk.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@fast", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "loading", + "stale", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "LATENCY": { + "summary": "A container for latency diagnostics commands.", + "since": "2.8.13", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "LATENCY DOCTOR": { + "summary": "Returns a human-readable latency analysis report.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_nodes", + "response_policy:special" + ] + }, + "LATENCY GRAPH": { + "summary": "Returns a latency graph for an event.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "event", + "type": "string", + "display_text": "event" + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_nodes", + "response_policy:special" + ] + }, + "LATENCY HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "LATENCY HISTOGRAM": { + "summary": "Returns the cumulative distribution of latencies of a subset or all commands.", + "since": "7.0.0", + "group": "server", + "complexity": "O(N) where N is the number of commands with latency information being retrieved.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "command", + "type": "string", + "display_text": "command", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_nodes", + "response_policy:special" + ] + }, + "LATENCY HISTORY": { + "summary": "Returns timestamp-latency samples for an event.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "event", + "type": "string", + "display_text": "event" + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_nodes", + "response_policy:special" + ] + }, + "LATENCY LATEST": { + "summary": "Returns the latest latency samples for all events.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "nondeterministic_output", + "request_policy:all_nodes", + "response_policy:special" + ] + }, + "LATENCY RESET": { + "summary": "Resets the latency data for one or more events.", + "since": "2.8.13", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "event", + "type": "string", + "display_text": "event", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:agg_sum" + ] + }, + "LCS": { + "summary": "Finds the longest common substring.", + "since": "7.0.0", + "group": "string", + "complexity": "O(N*M) where N and M are the lengths of s1 and s2, respectively", + "acl_categories": [ + "@read", + "@string", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key1", + "type": "key", + "display_text": "key1", + "key_spec_index": 0 + }, + { + "name": "key2", + "type": "key", + "display_text": "key2", + "key_spec_index": 0 + }, + { + "name": "len", + "type": "pure-token", + "display_text": "len", + "token": "LEN", + "optional": true + }, + { + "name": "idx", + "type": "pure-token", + "display_text": "idx", + "token": "IDX", + "optional": true + }, + { + "name": "min-match-len", + "type": "integer", + "display_text": "min-match-len", + "token": "MINMATCHLEN", + "optional": true + }, + { + "name": "withmatchlen", + "type": "pure-token", + "display_text": "withmatchlen", + "token": "WITHMATCHLEN", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "LINDEX": { + "summary": "Returns an element from a list by its index.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N) where N is the number of elements to traverse to get to the element at index. This makes asking for the first or the last element of the list O(1).", + "acl_categories": [ + "@read", + "@list", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer", + "display_text": "index" + } + ], + "command_flags": [ + "readonly" + ] + }, + "LINSERT": { + "summary": "Inserts an element before or after another element in a list.", + "since": "2.2.0", + "group": "list", + "complexity": "O(N) where N is the number of elements to traverse before seeing the value pivot. This means that inserting somewhere on the left end on the list (head) can be considered O(1) and inserting somewhere on the right end (tail) is O(N).", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "where", + "type": "oneof", + "arguments": [ + { + "name": "before", + "type": "pure-token", + "display_text": "before", + "token": "BEFORE" + }, + { + "name": "after", + "type": "pure-token", + "display_text": "after", + "token": "AFTER" + } + ] + }, + { + "name": "pivot", + "type": "string", + "display_text": "pivot" + }, + { + "name": "element", + "type": "string", + "display_text": "element" + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "LLEN": { + "summary": "Returns the length of a list.", + "since": "1.0.0", + "group": "list", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@list", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "LMOVE": { + "summary": "Returns an element after popping it from one list and pushing it to another. Deletes the list if the last element was moved.", + "since": "6.2.0", + "group": "list", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + }, + { + "name": "wherefrom", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + }, + { + "name": "whereto", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "LMPOP": { + "summary": "Returns multiple elements from a list after removing them. Deletes the list if the last element was popped.", + "since": "7.0.0", + "group": "list", + "complexity": "O(N+M) where N is the number of provided keys and M is the number of elements returned.", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "where", + "type": "oneof", + "arguments": [ + { + "name": "left", + "type": "pure-token", + "display_text": "left", + "token": "LEFT" + }, + { + "name": "right", + "type": "pure-token", + "display_text": "right", + "token": "RIGHT" + } + ] + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "write", + "movablekeys" + ] + }, + "LOLWUT": { + "summary": "Displays computer art and the Redis version", + "since": "5.0.0", + "group": "server", + "acl_categories": [ + "@read", + "@fast" + ], + "arity": -1, + "arguments": [ + { + "name": "version", + "type": "integer", + "display_text": "version", + "token": "VERSION", + "optional": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "LPOP": { + "summary": "Returns the first elements in a list after removing it. Deletes the list if the last element was popped.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N) where N is the number of elements returned", + "history": [ + [ + "6.2.0", + "Added the `count` argument." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "since": "6.2.0", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "LPOS": { + "summary": "Returns the index of matching elements in a list.", + "since": "6.0.6", + "group": "list", + "complexity": "O(N) where N is the number of elements in the list, for the average case. When searching for elements near the head or the tail of the list, or when the MAXLEN option is provided, the command may run in constant time.", + "acl_categories": [ + "@read", + "@list", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element" + }, + { + "name": "rank", + "type": "integer", + "display_text": "rank", + "token": "RANK", + "optional": true + }, + { + "name": "num-matches", + "type": "integer", + "display_text": "num-matches", + "token": "COUNT", + "optional": true + }, + { + "name": "len", + "type": "integer", + "display_text": "len", + "token": "MAXLEN", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "LPUSH": { + "summary": "Prepends one or more elements to a list. Creates the key if it doesn't exist.", + "since": "1.0.0", + "group": "list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "history": [ + [ + "2.4.0", + "Accepts multiple `element` arguments." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element", + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "LPUSHX": { + "summary": "Prepends one or more elements to a list only when the list exists.", + "since": "2.2.0", + "group": "list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "history": [ + [ + "4.0.0", + "Accepts multiple `element` arguments." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element", + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "LRANGE": { + "summary": "Returns a range of elements from a list.", + "since": "1.0.0", + "group": "list", + "complexity": "O(S+N) where S is the distance of start offset from HEAD for small lists, from nearest end (HEAD or TAIL) for large lists; and N is the number of elements in the specified range.", + "acl_categories": [ + "@read", + "@list", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "stop", + "type": "integer", + "display_text": "stop" + } + ], + "command_flags": [ + "readonly" + ] + }, + "LREM": { + "summary": "Removes elements from a list. Deletes the list if the last element was removed.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N+M) where N is the length of the list and M is the number of elements removed.", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + }, + { + "name": "element", + "type": "string", + "display_text": "element" + } + ], + "command_flags": [ + "write" + ] + }, + "LSET": { + "summary": "Sets the value of an element in a list by its index.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N) where N is the length of the list. Setting either the first or the last element of the list is O(1).", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "index", + "type": "integer", + "display_text": "index" + }, + { + "name": "element", + "type": "string", + "display_text": "element" + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "LTRIM": { + "summary": "Removes elements from both ends a list. Deletes the list if all elements were trimmed.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N) where N is the number of elements to be removed by the operation.", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "stop", + "type": "integer", + "display_text": "stop" + } + ], + "command_flags": [ + "write" + ] + }, + "MEMORY": { + "summary": "A container for memory diagnostics commands.", + "since": "4.0.0", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "MEMORY DOCTOR": { + "summary": "Outputs a memory problems report.", + "since": "4.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "hints": [ + "nondeterministic_output", + "request_policy:all_shards", + "response_policy:special" + ] + }, + "MEMORY HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "4.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "MEMORY MALLOC-STATS": { + "summary": "Returns the allocator statistics.", + "since": "4.0.0", + "group": "server", + "complexity": "Depends on how much memory is allocated, could be slow", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "hints": [ + "nondeterministic_output", + "request_policy:all_shards", + "response_policy:special" + ] + }, + "MEMORY PURGE": { + "summary": "Asks the allocator to release memory.", + "since": "4.0.0", + "group": "server", + "complexity": "Depends on how much memory is allocated, could be slow", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "MEMORY STATS": { + "summary": "Returns details about memory usage.", + "since": "4.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "hints": [ + "nondeterministic_output", + "request_policy:all_shards", + "response_policy:special" + ] + }, + "MEMORY USAGE": { + "summary": "Estimates the memory usage of a key.", + "since": "4.0.0", + "group": "server", + "complexity": "O(N) where N is the number of samples.", + "acl_categories": [ + "@read", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "SAMPLES", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "MGET": { + "summary": "Atomically returns the string values of one or more keys.", + "since": "1.0.0", + "group": "string", + "complexity": "O(N) where N is the number of keys to retrieve.", + "acl_categories": [ + "@read", + "@string", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "request_policy:multi_shard" + ] + }, + "MIGRATE": { + "summary": "Atomically transfers a key from one Redis instance to another.", + "since": "2.6.0", + "group": "generic", + "complexity": "This command actually executes a DUMP+DEL in the source instance, and a RESTORE in the target instance. See the pages of these commands for time complexity. Also an O(N) data transfer between the two instances is performed.", + "history": [ + [ + "3.0.0", + "Added the `COPY` and `REPLACE` options." + ], + [ + "3.0.6", + "Added the `KEYS` option." + ], + [ + "4.0.7", + "Added the `AUTH` option." + ], + [ + "6.0.0", + "Added the `AUTH2` option." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@slow", + "@dangerous" + ], + "arity": -6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 3 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "KEYS", + "startfrom": -2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true, + "incomplete": true + } + ], + "arguments": [ + { + "name": "host", + "type": "string", + "display_text": "host" + }, + { + "name": "port", + "type": "integer", + "display_text": "port" + }, + { + "name": "key-selector", + "type": "oneof", + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "empty-string", + "type": "pure-token", + "display_text": "empty-string", + "token": "" + } + ] + }, + { + "name": "destination-db", + "type": "integer", + "display_text": "destination-db" + }, + { + "name": "timeout", + "type": "integer", + "display_text": "timeout" + }, + { + "name": "copy", + "type": "pure-token", + "display_text": "copy", + "token": "COPY", + "since": "3.0.0", + "optional": true + }, + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE", + "since": "3.0.0", + "optional": true + }, + { + "name": "authentication", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "auth", + "type": "string", + "display_text": "password", + "token": "AUTH", + "since": "4.0.7" + }, + { + "name": "auth2", + "type": "block", + "token": "AUTH2", + "since": "6.0.0", + "arguments": [ + { + "name": "username", + "type": "string", + "display_text": "username" + }, + { + "name": "password", + "type": "string", + "display_text": "password" + } + ] + } + ] + }, + { + "name": "keys", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "token": "KEYS", + "since": "3.0.6", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "write", + "movablekeys" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "MODULE": { + "summary": "A container for module commands.", + "since": "4.0.0", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "MODULE HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "MODULE LIST": { + "summary": "Returns all loaded modules.", + "since": "4.0.0", + "group": "server", + "complexity": "O(N) where N is the number of loaded modules.", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "noscript" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "MODULE LOAD": { + "summary": "Loads a module.", + "since": "4.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "path", + "type": "string", + "display_text": "path" + }, + { + "name": "arg", + "type": "string", + "display_text": "arg", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "MODULE LOADEX": { + "summary": "Loads a module using extended parameters.", + "since": "7.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "path", + "type": "string", + "display_text": "path" + }, + { + "name": "configs", + "type": "block", + "token": "CONFIG", + "optional": true, + "multiple": true, + "multiple_token": true, + "arguments": [ + { + "name": "name", + "type": "string", + "display_text": "name" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + }, + { + "name": "args", + "type": "string", + "display_text": "args", + "token": "ARGS", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "MODULE UNLOAD": { + "summary": "Unloads a module.", + "since": "4.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "name", + "type": "string", + "display_text": "name" + } + ], + "command_flags": [ + "admin", + "noscript", + "no_async_loading" + ] + }, + "MONITOR": { + "summary": "Listens for all requests received by the server in real-time.", + "since": "1.0.0", + "group": "server", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale" + ] + }, + "MOVE": { + "summary": "Moves a key to another database.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "db", + "type": "integer", + "display_text": "db" + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "MSET": { + "summary": "Atomically creates or modifies the string values of one or more keys.", + "since": "1.0.1", + "group": "string", + "complexity": "O(N) where N is the number of keys to set.", + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 2, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ], + "hints": [ + "request_policy:multi_shard", + "response_policy:all_succeeded" + ] + }, + "MSETNX": { + "summary": "Atomically modifies the string values of one or more keys only when all keys don't exist.", + "since": "1.0.1", + "group": "string", + "complexity": "O(N) where N is the number of keys to set.", + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 2, + "limit": 0 + } + }, + "OW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "MULTI": { + "summary": "Starts a transaction.", + "since": "1.2.0", + "group": "transactions", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@transaction" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "OBJECT": { + "summary": "A container for object introspection commands.", + "since": "2.2.3", + "group": "generic", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "OBJECT ENCODING": { + "summary": "Returns the internal encoding of a Redis object.", + "since": "2.2.3", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "OBJECT FREQ": { + "summary": "Returns the logarithmic access frequency counter of a Redis object.", + "since": "4.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "OBJECT HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "6.2.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "OBJECT IDLETIME": { + "summary": "Returns the time since the last access to a Redis object.", + "since": "2.2.3", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "OBJECT REFCOUNT": { + "summary": "Returns the reference count of a value of a key.", + "since": "2.2.3", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "PERSIST": { + "summary": "Removes the expiration time of a key.", + "since": "2.2.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "PEXPIRE": { + "summary": "Sets the expiration time of a key in milliseconds.", + "since": "2.6.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added options: `NX`, `XX`, `GT` and `LT`." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds" + }, + { + "name": "condition", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + }, + { + "name": "gt", + "type": "pure-token", + "display_text": "gt", + "token": "GT" + }, + { + "name": "lt", + "type": "pure-token", + "display_text": "lt", + "token": "LT" + } + ] + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "PEXPIREAT": { + "summary": "Sets the expiration time of a key to a Unix milliseconds timestamp.", + "since": "2.6.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added options: `NX`, `XX`, `GT` and `LT`." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "unix-time-milliseconds", + "type": "unix-time", + "display_text": "unix-time-milliseconds" + }, + { + "name": "condition", + "type": "oneof", + "since": "7.0.0", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + }, + { + "name": "gt", + "type": "pure-token", + "display_text": "gt", + "token": "GT" + }, + { + "name": "lt", + "type": "pure-token", + "display_text": "lt", + "token": "LT" + } + ] + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "PEXPIRETIME": { + "summary": "Returns the expiration time of a key as a Unix milliseconds timestamp.", + "since": "7.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "PFADD": { + "summary": "Adds elements to a HyperLogLog key. Creates the key if it doesn't exist.", + "since": "2.8.9", + "group": "hyperloglog", + "complexity": "O(1) to add every element.", + "acl_categories": [ + "@write", + "@hyperloglog", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "PFCOUNT": { + "summary": "Returns the approximated cardinality of the set(s) observed by the HyperLogLog key(s).", + "since": "2.8.9", + "group": "hyperloglog", + "complexity": "O(1) with a very small average constant time when called with a single key. O(N) with N being the number of keys, and much bigger constant times, when called with multiple keys.", + "acl_categories": [ + "@read", + "@hyperloglog", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "notes": "RW because it may change the internal representation of the key, and propagate to replicas", + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "PFDEBUG": { + "summary": "Internal commands for debugging HyperLogLog values.", + "since": "2.8.9", + "group": "hyperloglog", + "complexity": "N/A", + "acl_categories": [ + "@write", + "@hyperloglog", + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true + } + ], + "arguments": [ + { + "name": "subcommand", + "type": "string", + "display_text": "subcommand" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "write", + "denyoom", + "admin" + ], + "doc_flags": [ + "syscmd" + ] + }, + "PFMERGE": { + "summary": "Merges one or more HyperLogLog values into a single key.", + "since": "2.8.9", + "group": "hyperloglog", + "complexity": "O(N) to merge N HyperLogLogs, but with high constant times.", + "acl_categories": [ + "@write", + "@hyperloglog", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "insert": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destkey", + "type": "key", + "display_text": "destkey", + "key_spec_index": 0 + }, + { + "name": "sourcekey", + "type": "key", + "display_text": "sourcekey", + "key_spec_index": 1, + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "PFSELFTEST": { + "summary": "An internal command for testing HyperLogLog values.", + "since": "2.8.9", + "group": "hyperloglog", + "complexity": "N/A", + "acl_categories": [ + "@hyperloglog", + "@admin", + "@slow", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "admin" + ], + "doc_flags": [ + "syscmd" + ] + }, + "PING": { + "summary": "Returns the server's liveliness response.", + "since": "1.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": -1, + "arguments": [ + { + "name": "message", + "type": "string", + "display_text": "message", + "optional": true + } + ], + "command_flags": [ + "fast" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:all_succeeded" + ] + }, + "PSETEX": { + "summary": "Sets both string value and expiration time in milliseconds of a key. The key is created if it doesn't exist.", + "since": "2.6.0", + "group": "string", + "complexity": "O(1)", + "deprecated_since": "2.6.12", + "replaced_by": "`SET` with the `PX` argument", + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom" + ], + "doc_flags": [ + "deprecated" + ] + }, + "PSUBSCRIBE": { + "summary": "Listens for messages published to channels that match one or more patterns.", + "since": "2.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of patterns to subscribe to.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "PSYNC": { + "summary": "An internal command used in replication.", + "since": "2.8.0", + "group": "server", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -3, + "arguments": [ + { + "name": "replicationid", + "type": "string", + "display_text": "replicationid" + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + } + ], + "command_flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "PTTL": { + "summary": "Returns the expiration time in milliseconds of a key.", + "since": "2.6.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "2.8.0", + "Added the -2 reply." + ] + ], + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "PUBLISH": { + "summary": "Posts a message to a channel.", + "since": "2.0.0", + "group": "pubsub", + "complexity": "O(N+M) where N is the number of clients subscribed to the receiving channel and M is the total number of subscribed patterns (by any client).", + "acl_categories": [ + "@pubsub", + "@fast" + ], + "arity": 3, + "arguments": [ + { + "name": "channel", + "type": "string", + "display_text": "channel" + }, + { + "name": "message", + "type": "string", + "display_text": "message" + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale", + "fast" + ] + }, + "PUBSUB": { + "summary": "A container for Pub/Sub commands.", + "since": "2.8.0", + "group": "pubsub", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "PUBSUB CHANNELS": { + "summary": "Returns the active channels.", + "since": "2.8.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of active channels, and assuming constant time pattern matching (relatively short channels and patterns)", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "optional": true + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "PUBSUB HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "6.2.0", + "group": "pubsub", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "PUBSUB NUMPAT": { + "summary": "Returns a count of unique pattern subscriptions.", + "since": "2.8.0", + "group": "pubsub", + "complexity": "O(1)", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": 2, + "command_flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "PUBSUB NUMSUB": { + "summary": "Returns a count of subscribers to channels.", + "since": "2.8.0", + "group": "pubsub", + "complexity": "O(N) for the NUMSUB subcommand, where N is the number of requested channels", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "channel", + "type": "string", + "display_text": "channel", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "PUBSUB SHARDCHANNELS": { + "summary": "Returns the active shard channels.", + "since": "7.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of active shard channels, and assuming constant time pattern matching (relatively short shard channels).", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "optional": true + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "PUBSUB SHARDNUMSUB": { + "summary": "Returns the count of subscribers of shard channels.", + "since": "7.0.0", + "group": "pubsub", + "complexity": "O(N) for the SHARDNUMSUB subcommand, where N is the number of requested shard channels", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "shardchannel", + "type": "string", + "display_text": "shardchannel", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale" + ] + }, + "PUNSUBSCRIBE": { + "summary": "Stops listening to messages published to channels that match one or more patterns.", + "since": "2.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of patterns to unsubscribe.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -1, + "arguments": [ + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "QUIT": { + "summary": "Closes the connection.", + "since": "1.0.0", + "group": "connection", + "complexity": "O(1)", + "deprecated_since": "7.2.0", + "replaced_by": "just closing the connection", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": -1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ], + "doc_flags": [ + "deprecated" + ] + }, + "RANDOMKEY": { + "summary": "Returns a random key name from the database.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": 1, + "command_flags": [ + "readonly" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:special", + "nondeterministic_output" + ] + }, + "READONLY": { + "summary": "Enables read-only queries for a connection to a Redis Cluster replica node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 1, + "command_flags": [ + "loading", + "stale", + "fast" + ] + }, + "READWRITE": { + "summary": "Enables read-write queries for a connection to a Reids Cluster replica node.", + "since": "3.0.0", + "group": "cluster", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 1, + "command_flags": [ + "loading", + "stale", + "fast" + ] + }, + "RENAME": { + "summary": "Renames a key and overwrites the destination.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@write", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "newkey", + "type": "key", + "display_text": "newkey", + "key_spec_index": 1 + } + ], + "command_flags": [ + "write" + ] + }, + "RENAMENX": { + "summary": "Renames a key only when the target key name doesn't exist.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "3.2.0", + "The command no longer returns an error when source and destination names are the same." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "newkey", + "type": "key", + "display_text": "newkey", + "key_spec_index": 1 + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "REPLCONF": { + "summary": "An internal command for configuring the replication stream.", + "since": "3.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -1, + "command_flags": [ + "admin", + "noscript", + "loading", + "stale", + "allow_busy" + ], + "doc_flags": [ + "syscmd" + ] + }, + "REPLICAOF": { + "summary": "Configures a server as replica of another, or promotes it to a master.", + "since": "5.0.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "args", + "type": "oneof", + "arguments": [ + { + "name": "host-port", + "type": "block", + "arguments": [ + { + "name": "host", + "type": "string", + "display_text": "host" + }, + { + "name": "port", + "type": "integer", + "display_text": "port" + } + ] + }, + { + "name": "no-one", + "type": "block", + "arguments": [ + { + "name": "no", + "type": "pure-token", + "display_text": "no", + "token": "NO" + }, + { + "name": "one", + "type": "pure-token", + "display_text": "one", + "token": "ONE" + } + ] + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "stale", + "no_async_loading" + ] + }, + "RESET": { + "summary": "Resets the connection.", + "since": "6.2.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "no_auth", + "allow_busy" + ] + }, + "RESTORE": { + "summary": "Creates a key from the serialized representation of a value.", + "since": "2.6.0", + "group": "generic", + "complexity": "O(1) to create the new key and additional O(N*M) to reconstruct the serialized value, where N is the number of Redis objects composing the value and M their average size. For small string values the time complexity is thus O(1)+O(1*M) where M is small, so simply O(1). However for sorted set values the complexity is O(N*M*log(N)) because inserting values into sorted sets is O(log(N)).", + "history": [ + [ + "3.0.0", + "Added the `REPLACE` modifier." + ], + [ + "5.0.0", + "Added the `ABSTTL` modifier." + ], + [ + "5.0.0", + "Added the `IDLETIME` and `FREQ` options." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@slow", + "@dangerous" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "ttl", + "type": "integer", + "display_text": "ttl" + }, + { + "name": "serialized-value", + "type": "string", + "display_text": "serialized-value" + }, + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE", + "since": "3.0.0", + "optional": true + }, + { + "name": "absttl", + "type": "pure-token", + "display_text": "absttl", + "token": "ABSTTL", + "since": "5.0.0", + "optional": true + }, + { + "name": "seconds", + "type": "integer", + "display_text": "seconds", + "token": "IDLETIME", + "since": "5.0.0", + "optional": true + }, + { + "name": "frequency", + "type": "integer", + "display_text": "frequency", + "token": "FREQ", + "since": "5.0.0", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "RESTORE-ASKING": { + "summary": "An internal command for migrating keys in a cluster.", + "since": "3.0.0", + "group": "server", + "complexity": "O(1) to create the new key and additional O(N*M) to reconstruct the serialized value, where N is the number of Redis objects composing the value and M their average size. For small string values the time complexity is thus O(1)+O(1*M) where M is small, so simply O(1). However for sorted set values the complexity is O(N*M*log(N)) because inserting values into sorted sets is O(log(N)).", + "history": [ + [ + "3.0.0", + "Added the `REPLACE` modifier." + ], + [ + "5.0.0", + "Added the `ABSTTL` modifier." + ], + [ + "5.0.0", + "Added the `IDLETIME` and `FREQ` options." + ] + ], + "acl_categories": [ + "@keyspace", + "@write", + "@slow", + "@dangerous" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "ttl", + "type": "integer", + "display_text": "ttl" + }, + { + "name": "serialized-value", + "type": "string", + "display_text": "serialized-value" + }, + { + "name": "replace", + "type": "pure-token", + "display_text": "replace", + "token": "REPLACE", + "since": "3.0.0", + "optional": true + }, + { + "name": "absttl", + "type": "pure-token", + "display_text": "absttl", + "token": "ABSTTL", + "since": "5.0.0", + "optional": true + }, + { + "name": "seconds", + "type": "integer", + "display_text": "seconds", + "token": "IDLETIME", + "since": "5.0.0", + "optional": true + }, + { + "name": "frequency", + "type": "integer", + "display_text": "frequency", + "token": "FREQ", + "since": "5.0.0", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom", + "asking" + ], + "doc_flags": [ + "syscmd" + ] + }, + "ROLE": { + "summary": "Returns the replication role.", + "since": "2.8.12", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@fast", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast" + ] + }, + "RPOP": { + "summary": "Returns and removes the last elements of a list. Deletes the list if the last element was popped.", + "since": "1.0.0", + "group": "list", + "complexity": "O(N) where N is the number of elements returned", + "history": [ + [ + "6.2.0", + "Added the `count` argument." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "since": "6.2.0", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "RPOPLPUSH": { + "summary": "Returns the last element of a list after removing and pushing it to another list. Deletes the list if the last element was popped.", + "since": "1.2.0", + "group": "list", + "complexity": "O(1)", + "deprecated_since": "6.2.0", + "replaced_by": "`LMOVE` with the `RIGHT` and `LEFT` arguments", + "acl_categories": [ + "@write", + "@list", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + } + ], + "command_flags": [ + "write", + "denyoom" + ], + "doc_flags": [ + "deprecated" + ] + }, + "RPUSH": { + "summary": "Appends one or more elements to a list. Creates the key if it doesn't exist.", + "since": "1.0.0", + "group": "list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "history": [ + [ + "2.4.0", + "Accepts multiple `element` arguments." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element", + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "RPUSHX": { + "summary": "Appends an element to a list only when the list exists.", + "since": "2.2.0", + "group": "list", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "history": [ + [ + "4.0.0", + "Accepts multiple `element` arguments." + ] + ], + "acl_categories": [ + "@write", + "@list", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "element", + "type": "string", + "display_text": "element", + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "SADD": { + "summary": "Adds one or more members to a set. Creates the key if it doesn't exist.", + "since": "1.0.0", + "group": "set", + "complexity": "O(1) for each element added, so O(N) to add N elements when the command is called with multiple arguments.", + "history": [ + [ + "2.4.0", + "Accepts multiple `member` arguments." + ] + ], + "acl_categories": [ + "@write", + "@set", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "SAVE": { + "summary": "Synchronously saves the database(s) to disk.", + "since": "1.0.0", + "group": "server", + "complexity": "O(N) where N is the total number of keys in all databases", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "SCAN": { + "summary": "Iterates over the key names in the database.", + "since": "2.8.0", + "group": "generic", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.", + "history": [ + [ + "6.0.0", + "Added the `TYPE` subcommand." + ] + ], + "acl_categories": [ + "@keyspace", + "@read", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "cursor", + "type": "integer", + "display_text": "cursor" + }, + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "token": "MATCH", + "optional": true + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + }, + { + "name": "type", + "type": "string", + "display_text": "type", + "token": "TYPE", + "since": "6.0.0", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output", + "request_policy:special", + "response_policy:special" + ] + }, + "SCARD": { + "summary": "Returns the number of members in a set.", + "since": "1.0.0", + "group": "set", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@set", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "SCRIPT": { + "summary": "A container for Lua scripts management commands.", + "since": "2.6.0", + "group": "scripting", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "SCRIPT DEBUG": { + "summary": "Sets the debug mode of server-side Lua scripts.", + "since": "3.2.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 3, + "arguments": [ + { + "name": "mode", + "type": "oneof", + "arguments": [ + { + "name": "yes", + "type": "pure-token", + "display_text": "yes", + "token": "YES" + }, + { + "name": "sync", + "type": "pure-token", + "display_text": "sync", + "token": "SYNC" + }, + { + "name": "no", + "type": "pure-token", + "display_text": "no", + "token": "NO" + } + ] + } + ], + "command_flags": [ + "noscript" + ] + }, + "SCRIPT EXISTS": { + "summary": "Determines whether server-side Lua scripts exist in the script cache.", + "since": "2.6.0", + "group": "scripting", + "complexity": "O(N) with N being the number of scripts to check (so checking a single script is an O(1) operation).", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -3, + "arguments": [ + { + "name": "sha1", + "type": "string", + "display_text": "sha1", + "multiple": true + } + ], + "command_flags": [ + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:agg_logical_and" + ] + }, + "SCRIPT FLUSH": { + "summary": "Removes all server-side Lua scripts from the script cache.", + "since": "2.6.0", + "group": "scripting", + "complexity": "O(N) with N being the number of scripts in cache", + "history": [ + [ + "6.2.0", + "Added the `ASYNC` and `SYNC` flushing mode modifiers." + ] + ], + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": -2, + "arguments": [ + { + "name": "flush-type", + "type": "oneof", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "async", + "type": "pure-token", + "display_text": "async", + "token": "ASYNC" + }, + { + "name": "sync", + "type": "pure-token", + "display_text": "sync", + "token": "SYNC" + } + ] + } + ], + "command_flags": [ + "noscript" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "SCRIPT HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "SCRIPT KILL": { + "summary": "Terminates a server-side Lua script during execution.", + "since": "2.6.0", + "group": "scripting", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 2, + "command_flags": [ + "noscript", + "allow_busy" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:one_succeeded" + ] + }, + "SCRIPT LOAD": { + "summary": "Loads a server-side Lua script to the script cache.", + "since": "2.6.0", + "group": "scripting", + "complexity": "O(N) with N being the length in bytes of the script body.", + "acl_categories": [ + "@slow", + "@scripting" + ], + "arity": 3, + "arguments": [ + { + "name": "script", + "type": "string", + "display_text": "script" + } + ], + "command_flags": [ + "noscript", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "SDIFF": { + "summary": "Returns the difference of multiple sets.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "SDIFFSTORE": { + "summary": "Stores the difference of multiple sets in a key.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "acl_categories": [ + "@write", + "@set", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SELECT": { + "summary": "Changes the selected database.", + "since": "1.0.0", + "group": "connection", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@connection" + ], + "arity": 2, + "arguments": [ + { + "name": "index", + "type": "integer", + "display_text": "index" + } + ], + "command_flags": [ + "loading", + "stale", + "fast" + ] + }, + "SET": { + "summary": "Sets the string value of a key, ignoring its type. The key is created if it doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "history": [ + [ + "2.6.12", + "Added the `EX`, `PX`, `NX` and `XX` options." + ], + [ + "6.0.0", + "Added the `KEEPTTL` option." + ], + [ + "6.2.0", + "Added the `GET`, `EXAT` and `PXAT` option." + ], + [ + "7.0.0", + "Allowed the `NX` and `GET` options to be used together." + ] + ], + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "notes": "RW and ACCESS due to the optional `GET` argument", + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true, + "variable_flags": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + }, + { + "name": "condition", + "type": "oneof", + "since": "2.6.12", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + } + ] + }, + { + "name": "get", + "type": "pure-token", + "display_text": "get", + "token": "GET", + "since": "6.2.0", + "optional": true + }, + { + "name": "expiration", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "seconds", + "type": "integer", + "display_text": "seconds", + "token": "EX", + "since": "2.6.12" + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds", + "token": "PX", + "since": "2.6.12" + }, + { + "name": "unix-time-seconds", + "type": "unix-time", + "display_text": "unix-time-seconds", + "token": "EXAT", + "since": "6.2.0" + }, + { + "name": "unix-time-milliseconds", + "type": "unix-time", + "display_text": "unix-time-milliseconds", + "token": "PXAT", + "since": "6.2.0" + }, + { + "name": "keepttl", + "type": "pure-token", + "display_text": "keepttl", + "token": "KEEPTTL", + "since": "6.0.0" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SETBIT": { + "summary": "Sets or clears the bit at offset of the string value. Creates the key if it doesn't exist.", + "since": "2.2.0", + "group": "bitmap", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@bitmap", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "value", + "type": "integer", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SETEX": { + "summary": "Sets the string value and expiration time of a key. Creates the key if it doesn't exist.", + "since": "2.0.0", + "group": "string", + "complexity": "O(1)", + "deprecated_since": "2.6.12", + "replaced_by": "`SET` with the `EX` argument", + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "seconds", + "type": "integer", + "display_text": "seconds" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom" + ], + "doc_flags": [ + "deprecated" + ] + }, + "SETNX": { + "summary": "Set the string value of a key only when the key doesn't exist.", + "since": "1.0.0", + "group": "string", + "complexity": "O(1)", + "deprecated_since": "2.6.12", + "replaced_by": "`SET` with the `NX` argument", + "acl_categories": [ + "@write", + "@string", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ], + "doc_flags": [ + "deprecated" + ] + }, + "SETRANGE": { + "summary": "Overwrites a part of a string value with another by an offset. Creates the key if it doesn't exist.", + "since": "2.2.0", + "group": "string", + "complexity": "O(1), not counting the time taken to copy the new string in place. Usually, this string is very small so the amortized complexity is O(1). Otherwise, complexity is O(M) with M being the length of the value argument.", + "acl_categories": [ + "@write", + "@string", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SHUTDOWN": { + "summary": "Synchronously saves the database(s) to disk and shuts down the Redis server.", + "since": "1.0.0", + "group": "server", + "complexity": "O(N) when saving, where N is the total number of keys in all databases when saving data, otherwise O(1)", + "history": [ + [ + "7.0.0", + "Added the `NOW`, `FORCE` and `ABORT` modifiers." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -1, + "arguments": [ + { + "name": "save-selector", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "nosave", + "type": "pure-token", + "display_text": "nosave", + "token": "NOSAVE" + }, + { + "name": "save", + "type": "pure-token", + "display_text": "save", + "token": "SAVE" + } + ] + }, + { + "name": "now", + "type": "pure-token", + "display_text": "now", + "token": "NOW", + "since": "7.0.0", + "optional": true + }, + { + "name": "force", + "type": "pure-token", + "display_text": "force", + "token": "FORCE", + "since": "7.0.0", + "optional": true + }, + { + "name": "abort", + "type": "pure-token", + "display_text": "abort", + "token": "ABORT", + "since": "7.0.0", + "optional": true + } + ], + "command_flags": [ + "admin", + "noscript", + "loading", + "stale", + "no_multi", + "allow_busy" + ] + }, + "SINTER": { + "summary": "Returns the intersect of multiple sets.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "SINTERCARD": { + "summary": "Returns the number of members of the intersect of multiple sets.", + "since": "7.0.0", + "group": "set", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "limit", + "type": "integer", + "display_text": "limit", + "token": "LIMIT", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "SINTERSTORE": { + "summary": "Stores the intersect of multiple sets in a key.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.", + "acl_categories": [ + "@write", + "@set", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SISMEMBER": { + "summary": "Determines whether a member belongs to a set.", + "since": "1.0.0", + "group": "set", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@set", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "SLAVEOF": { + "summary": "Sets a Redis server as a replica of another, or promotes it to being a master.", + "since": "1.0.0", + "group": "server", + "complexity": "O(1)", + "deprecated_since": "5.0.0", + "replaced_by": "`REPLICAOF`", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "args", + "type": "oneof", + "arguments": [ + { + "name": "host-port", + "type": "block", + "arguments": [ + { + "name": "host", + "type": "string", + "display_text": "host" + }, + { + "name": "port", + "type": "integer", + "display_text": "port" + } + ] + }, + { + "name": "no-one", + "type": "block", + "arguments": [ + { + "name": "no", + "type": "pure-token", + "display_text": "no", + "token": "NO" + }, + { + "name": "one", + "type": "pure-token", + "display_text": "one", + "token": "ONE" + } + ] + } + ] + } + ], + "command_flags": [ + "admin", + "noscript", + "stale", + "no_async_loading" + ], + "doc_flags": [ + "deprecated" + ] + }, + "SLOWLOG": { + "summary": "A container for slow log commands.", + "since": "2.2.12", + "group": "server", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "SLOWLOG GET": { + "summary": "Returns the slow log's entries.", + "since": "2.2.12", + "group": "server", + "complexity": "O(N) where N is the number of entries returned", + "history": [ + [ + "4.0.0", + "Added client IP address, port and name to the reply." + ] + ], + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": -2, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "optional": true + } + ], + "command_flags": [ + "admin", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "nondeterministic_output" + ] + }, + "SLOWLOG HELP": { + "summary": "Show helpful text about the different subcommands", + "since": "6.2.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "SLOWLOG LEN": { + "summary": "Returns the number of entries in the slow log.", + "since": "2.2.12", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:agg_sum", + "nondeterministic_output" + ] + }, + "SLOWLOG RESET": { + "summary": "Clears all entries from the slow log.", + "since": "2.2.12", + "group": "server", + "complexity": "O(N) where N is the number of entries in the slowlog", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 2, + "command_flags": [ + "admin", + "loading", + "stale" + ], + "hints": [ + "request_policy:all_nodes", + "response_policy:all_succeeded" + ] + }, + "SMEMBERS": { + "summary": "Returns all members of a set.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the set cardinality.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "SMISMEMBER": { + "summary": "Determines whether multiple members belong to a set.", + "since": "6.2.0", + "group": "set", + "complexity": "O(N) where N is the number of elements being checked for membership", + "acl_categories": [ + "@read", + "@set", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "SMOVE": { + "summary": "Moves a member from one set to another.", + "since": "1.0.0", + "group": "set", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@set", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "source", + "type": "key", + "display_text": "source", + "key_spec_index": 0 + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 1 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "SORT": { + "summary": "Sorts the elements in a list, a set, or a sorted set, optionally storing the result.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(N+M*log(M)) where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is O(N).", + "acl_categories": [ + "@write", + "@set", + "@sortedset", + "@list", + "@slow", + "@dangerous" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + }, + { + "notes": "For the optional BY/GET keyword. It is marked 'unknown' because the key names derive from the content of the key we sort", + "begin_search": { + "type": "unknown", + "spec": {} + }, + "find_keys": { + "type": "unknown", + "spec": {} + }, + "RO": true, + "access": true + }, + { + "notes": "For the optional STORE keyword. It is marked 'unknown' because the keyword can appear anywhere in the argument array", + "begin_search": { + "type": "unknown", + "spec": {} + }, + "find_keys": { + "type": "unknown", + "spec": {} + }, + "OW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "by-pattern", + "type": "pattern", + "display_text": "pattern", + "key_spec_index": 1, + "token": "BY", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + }, + { + "name": "get-pattern", + "type": "pattern", + "display_text": "pattern", + "key_spec_index": 1, + "token": "GET", + "optional": true, + "multiple": true, + "multiple_token": true + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "sorting", + "type": "pure-token", + "display_text": "sorting", + "token": "ALPHA", + "optional": true + }, + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 2, + "token": "STORE", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "SORT_RO": { + "summary": "Returns the sorted elements of a list, a set, or a sorted set.", + "since": "7.0.0", + "group": "generic", + "complexity": "O(N+M*log(M)) where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is O(N).", + "acl_categories": [ + "@read", + "@set", + "@sortedset", + "@list", + "@slow", + "@dangerous" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + }, + { + "notes": "For the optional BY/GET keyword. It is marked 'unknown' because the key names derive from the content of the key we sort", + "begin_search": { + "type": "unknown", + "spec": {} + }, + "find_keys": { + "type": "unknown", + "spec": {} + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "by-pattern", + "type": "pattern", + "display_text": "pattern", + "key_spec_index": 1, + "token": "BY", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + }, + { + "name": "get-pattern", + "type": "pattern", + "display_text": "pattern", + "key_spec_index": 1, + "token": "GET", + "optional": true, + "multiple": true, + "multiple_token": true + }, + { + "name": "order", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "asc", + "type": "pure-token", + "display_text": "asc", + "token": "ASC" + }, + { + "name": "desc", + "type": "pure-token", + "display_text": "desc", + "token": "DESC" + } + ] + }, + { + "name": "sorting", + "type": "pure-token", + "display_text": "sorting", + "token": "ALPHA", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "SPOP": { + "summary": "Returns one or more random members from a set after removing them. Deletes the set if the last member was popped.", + "since": "1.0.0", + "group": "set", + "complexity": "Without the count argument O(1), otherwise O(N) where N is the value of the passed count.", + "history": [ + [ + "3.2.0", + "Added the `count` argument." + ] + ], + "acl_categories": [ + "@write", + "@set", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "since": "3.2.0", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "SPUBLISH": { + "summary": "Post a message to a shard channel", + "since": "7.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of clients subscribed to the receiving shard channel.", + "acl_categories": [ + "@pubsub", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "not_key": true + } + ], + "arguments": [ + { + "name": "shardchannel", + "type": "string", + "display_text": "shardchannel" + }, + { + "name": "message", + "type": "string", + "display_text": "message" + } + ], + "command_flags": [ + "pubsub", + "loading", + "stale", + "fast" + ] + }, + "SRANDMEMBER": { + "summary": "Get one or multiple random members from a set", + "since": "1.0.0", + "group": "set", + "complexity": "Without the count argument O(1), otherwise O(N) where N is the absolute value of the passed count.", + "history": [ + [ + "2.6.0", + "Added the optional `count` argument." + ] + ], + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "since": "2.6.0", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "SREM": { + "summary": "Removes one or more members from a set. Deletes the set if the last member was removed.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the number of members to be removed.", + "history": [ + [ + "2.4.0", + "Accepts multiple `member` arguments." + ] + ], + "acl_categories": [ + "@write", + "@set", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "SSCAN": { + "summary": "Iterates over members of a set.", + "since": "2.8.0", + "group": "set", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "cursor", + "type": "integer", + "display_text": "cursor" + }, + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "token": "MATCH", + "optional": true + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "SSUBSCRIBE": { + "summary": "Listens for messages published to shard channels.", + "since": "7.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of shard channels to subscribe to.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "not_key": true + } + ], + "arguments": [ + { + "name": "shardchannel", + "type": "string", + "display_text": "shardchannel", + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "STRLEN": { + "summary": "Returns the length of a string value.", + "since": "2.2.0", + "group": "string", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@string", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "SUBSCRIBE": { + "summary": "Listens for messages published to channels.", + "since": "2.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of channels to subscribe to.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -2, + "arguments": [ + { + "name": "channel", + "type": "string", + "display_text": "channel", + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "SUBSTR": { + "summary": "Returns a substring from a string value.", + "since": "1.0.0", + "group": "string", + "complexity": "O(N) where N is the length of the returned string. The complexity is ultimately determined by the returned length, but because creating a substring from an existing string is very cheap, it can be considered O(1) for small strings.", + "deprecated_since": "2.0.0", + "replaced_by": "`GETRANGE`", + "acl_categories": [ + "@read", + "@string", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "end", + "type": "integer", + "display_text": "end" + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "SUNION": { + "summary": "Returns the union of multiple sets.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "acl_categories": [ + "@read", + "@set", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output_order" + ] + }, + "SUNIONSTORE": { + "summary": "Stores the union of multiple sets in a key.", + "since": "1.0.0", + "group": "set", + "complexity": "O(N) where N is the total number of elements in all given sets.", + "acl_categories": [ + "@write", + "@set", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "SUNSUBSCRIBE": { + "summary": "Stops listening to messages posted to shard channels.", + "since": "7.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of shard channels to unsubscribe.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -1, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "not_key": true + } + ], + "arguments": [ + { + "name": "shardchannel", + "type": "string", + "display_text": "shardchannel", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "SWAPDB": { + "summary": "Swaps two Redis databases.", + "since": "4.0.0", + "group": "server", + "complexity": "O(N) where N is the count of clients watching or blocking on keys from both databases.", + "acl_categories": [ + "@keyspace", + "@write", + "@fast", + "@dangerous" + ], + "arity": 3, + "arguments": [ + { + "name": "index1", + "type": "integer", + "display_text": "index1" + }, + { + "name": "index2", + "type": "integer", + "display_text": "index2" + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "SYNC": { + "summary": "An internal command used in replication.", + "since": "1.0.0", + "group": "server", + "acl_categories": [ + "@admin", + "@slow", + "@dangerous" + ], + "arity": 1, + "command_flags": [ + "admin", + "noscript", + "no_async_loading", + "no_multi" + ] + }, + "TDIGEST.ADD": { + "summary": "Adds one or more observations to a t-digest sketch", + "complexity": "O(N), where N is the number of samples to add", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "values", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "value", + "type": "double" + } + ] + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.BYRANK": { + "summary": "Returns, for each input rank, an estimation of the value (floating-point) with that rank", + "complexity": "O(N) where N is the number of ranks specified", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "rank", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.BYREVRANK": { + "summary": "Returns, for each input reverse rank, an estimation of the value (floating-point) with that reverse rank", + "complexity": "O(N) where N is the number of reverse ranks specified.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "reverse_rank", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.CDF": { + "summary": "Returns, for each input value, an estimation of the fraction (floating-point) of (observations smaller than the given value + half the observations equal to the given value)", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.CREATE": { + "summary": "Allocates memory and initializes a new t-digest sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "compression", + "type": "integer", + "token": "COMPRESSION", + "optional": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.INFO": { + "summary": "Returns information and statistics about a t-digest sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.MAX": { + "summary": "Returns the maximum observation value from a t-digest sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.MERGE": { + "summary": "Merges multiple t-digest sketches into a single sketch", + "complexity": "O(N*K), where N is the number of centroids and K being the number of input sketches", + "arguments": [ + { + "name": "destination-key", + "type": "key" + }, + { + "name": "numkeys", + "type": "integer" + }, + { + "name": "source-key", + "type": "key", + "multiple": true + }, + { + "name": "config", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "compression", + "token": "COMPRESSION", + "type": "pure-token" + }, + { + "name": "compression", + "type": "integer" + } + ] + }, + { + "name": "override", + "type": "pure-token", + "token": "OVERRIDE", + "optional": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.MIN": { + "summary": "Returns the minimum observation value from a t-digest sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.QUANTILE": { + "summary": "Returns, for each input fraction, an estimation of the value (floating point) that is smaller than the given fraction of observations", + "complexity": "O(N) where N is the number of quantiles specified.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "quantile", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.RANK": { + "summary": "Returns, for each input value (floating-point), the estimated rank of the value (the number of observations in the sketch that are smaller than the value + half the number of observations that are equal to the value)", + "complexity": "O(N) where N is the number of values specified.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.RESET": { + "summary": "Resets a t-digest sketch: empty the sketch and re-initializes it.", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.REVRANK": { + "summary": "Returns, for each input value (floating-point), the estimated reverse rank of the value (the number of observations in the sketch that are larger than the value + half the number of observations that are equal to the value)", + "complexity": "O(N) where N is the number of values specified.", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "double", + "multiple": true + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TDIGEST.TRIMMED_MEAN": { + "summary": "Returns an estimation of the mean value from the sketch, excluding observation values outside the low and high cutoff quantiles", + "complexity": "O(N) where N is the number of centroids", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "low_cut_quantile", + "type": "double" + }, + { + "name": "high_cut_quantile", + "type": "double" + } + ], + "since": "2.4.0", + "group": "tdigest" + }, + "TIME": { + "summary": "Returns the server time.", + "since": "2.6.0", + "group": "server", + "complexity": "O(1)", + "acl_categories": [ + "@fast" + ], + "arity": 1, + "command_flags": [ + "loading", + "stale", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "TOPK.ADD": { + "summary": "Adds an item to a Top-k sketch. Multiple items can be added at the same time.", + "complexity": "O(n * k) where n is the number of items and k is the depth", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "items", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.COUNT": { + "summary": "Return the count for one or more items are in a sketch", + "complexity": "O(n) where n is the number of items", + "deprecated_since": "2.4", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.INCRBY": { + "summary": "Increases the count of one or more items by increment", + "complexity": "O(n * k * incr) where n is the number of items, k is the depth and incr is the increment", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "items", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "item", + "type": "string" + }, + { + "name": "increment", + "type": "integer" + } + ] + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.INFO": { + "summary": "Returns information about a sketch", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.LIST": { + "summary": "Return the full list of items in Top-K sketch.", + "complexity": "O(k*log(k)) where k is the value of top-k", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "withcount", + "token": "WITHCOUNT", + "type": "pure-token", + "optional": true + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.QUERY": { + "summary": "Checks whether one or more items are in a sketch", + "complexity": "O(n) where n is the number of items", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "item", + "type": "string", + "multiple": true + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOPK.RESERVE": { + "summary": "Initializes a Top-K sketch with specified parameters", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "topk", + "type": "integer" + }, + { + "name": "params", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "width", + "type": "integer" + }, + { + "name": "depth", + "type": "integer" + }, + { + "name": "decay", + "type": "double" + } + ] + } + ], + "since": "2.0.0", + "group": "topk" + }, + "TOUCH": { + "summary": "Returns the number of existing keys out of those specified after updating the time they were last accessed.", + "since": "3.2.1", + "group": "generic", + "complexity": "O(N) where N is the number of keys that will be touched.", + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "request_policy:multi_shard", + "response_policy:agg_sum" + ] + }, + "TS.ADD": { + "summary": "Append a sample to a time series", + "complexity": "O(M) when M is the amount of compaction rules or O(1) with no compaction", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "timestamp", + "type": "string" + }, + { + "name": "value", + "type": "double" + }, + { + "type": "integer", + "token": "RETENTION", + "name": "retentionPeriod", + "optional": true + }, + { + "token": "ENCODING", + "name": "enc", + "type": "oneof", + "arguments": [ + { + "name": "uncompressed", + "type": "pure-token", + "token": "UNCOMPRESSED" + }, + { + "name": "compressed", + "type": "pure-token", + "token": "COMPRESSED" + } + ], + "optional": true + }, + { + "type": "integer", + "token": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "oneof", + "token": "ON_DUPLICATE", + "name": "policy", + "arguments": [ + { + "name": "block", + "type": "pure-token", + "token": "BLOCK" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + } + ], + "optional": true + }, + { + "type": "block", + "name": "labels", + "token": "LABELS", + "optional": true, + "multiple": true, + "arguments": [ + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.ALTER": { + "summary": "Update the retention, chunk size, duplicate policy, and labels of an existing time series", + "complexity": "O(N) where N is the number of labels requested to update", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "type": "integer", + "token": "RETENTION", + "name": "retentionPeriod", + "optional": true + }, + { + "type": "integer", + "token": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "oneof", + "token": "DUPLICATE_POLICY", + "name": "policy", + "arguments": [ + { + "name": "block", + "type": "pure-token", + "token": "BLOCK" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + } + ], + "optional": true + }, + { + "type": "block", + "name": "labels", + "token": "LABELS", + "optional": true, + "multiple": true, + "arguments": [ + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.CREATE": { + "summary": "Create a new time series", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "type": "integer", + "token": "RETENTION", + "name": "retentionPeriod", + "optional": true + }, + { + "token": "ENCODING", + "name": "enc", + "type": "oneof", + "arguments": [ + { + "name": "uncompressed", + "type": "pure-token", + "token": "UNCOMPRESSED" + }, + { + "name": "compressed", + "type": "pure-token", + "token": "COMPRESSED" + } + ], + "optional": true + }, + { + "type": "integer", + "token": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "oneof", + "token": "DUPLICATE_POLICY", + "name": "policy", + "arguments": [ + { + "name": "block", + "type": "pure-token", + "token": "BLOCK" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + } + ], + "optional": true + }, + { + "type": "block", + "name": "labels", + "token": "LABELS", + "optional": true, + "multiple": true, + "arguments": [ + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.CREATERULE": { + "summary": "Create a compaction rule", + "complexity": "O(1)", + "arguments": [ + { + "name": "sourceKey", + "type": "key" + }, + { + "name": "destKey", + "type": "key" + }, + { + "type": "oneof", + "token": "AGGREGATION", + "name": "aggregator", + "arguments": [ + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "std.p", + "type": "pure-token", + "token": "STD.P" + }, + { + "name": "std.s", + "type": "pure-token", + "token": "STD.S" + }, + { + "name": "var.p", + "type": "pure-token", + "token": "VAR.P" + }, + { + "name": "var.s", + "type": "pure-token", + "token": "VAR.S" + }, + { + "name": "twa", + "type": "pure-token", + "token": "TWA", + "since": "1.8.0" + }, + { + "name": "countnan", + "type": "pure-token", + "token": "COUNTNAN", + "since": "8.6.0" + }, + { + "name": "countall", + "type": "pure-token", + "token": "COUNTALL", + "since": "8.6.0" + } + ] + }, + { + "name": "bucketDuration", + "type": "integer" + }, + { + "name": "alignTimestamp", + "type": "integer", + "optional": true, + "since": "1.8.0" + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.DECRBY": { + "summary": "Decrease the value of the sample with the maximum existing timestamp, or create a new sample with a value equal to the value of the sample with the maximum existing timestamp with a given decrement", + "complexity": "O(M) when M is the amount of compaction rules or O(1) with no compaction", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "double" + }, + { + "name": "timestamp", + "type": "string", + "token": "TIMESTAMP", + "optional": true + }, + { + "type": "integer", + "token": "RETENTION", + "name": "retentionPeriod", + "optional": true + }, + { + "name": "uncompressed", + "type": "pure-token", + "token": "UNCOMPRESSED", + "optional": true + }, + { + "type": "integer", + "token": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "block", + "name": "labels", + "token": "LABELS", + "optional": true, + "multiple": true, + "arguments": [ + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.DEL": { + "summary": "Delete all samples between two timestamps for a given time series", + "complexity": "O(N) where N is the number of data points that will be removed", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "from_timestamp", + "type": "integer" + }, + { + "name": "to_timestamp", + "type": "integer" + } + ], + "since": "1.6.0", + "group": "timeseries" + }, + "TS.DELETERULE": { + "summary": "Delete a compaction rule", + "complexity": "O(1)", + "arguments": [ + { + "name": "sourceKey", + "type": "key" + }, + { + "name": "destKey", + "type": "key" + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.GET": { + "summary": "Get the sample with the highest timestamp from a given time series", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "LATEST", + "type": "string", + "optional": true, + "since": "1.8.0" + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.INCRBY": { + "summary": "Increase the value of the sample with the maximum existing timestamp, or create a new sample with a value equal to the value of the sample with the maximum existing timestamp with a given increment", + "complexity": "O(M) when M is the amount of compaction rules or O(1) with no compaction", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "value", + "type": "double" + }, + { + "name": "timestamp", + "type": "string", + "token": "TIMESTAMP", + "optional": true + }, + { + "type": "integer", + "token": "RETENTION", + "name": "retentionPeriod", + "optional": true + }, + { + "name": "uncompressed", + "type": "pure-token", + "token": "UNCOMPRESSED", + "optional": true + }, + { + "type": "integer", + "token": "CHUNK_SIZE", + "name": "size", + "optional": true + }, + { + "type": "block", + "name": "labels", + "token": "LABELS", + "optional": true, + "multiple": true, + "arguments": [ + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.INFO": { + "summary": "Returns information and statistics for a time series", + "complexity": "O(1)", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "DEBUG", + "type": "string", + "optional": true + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.MADD": { + "summary": "Append new samples to one or more time series", + "complexity": "O(N*M) when N is the amount of series updated and M is the amount of compaction rules or O(N) with no compaction", + "arguments": [ + { + "type": "block", + "name": "ktv", + "multiple": true, + "arguments": [ + { + "type": "key", + "name": "key" + }, + { + "type": "string", + "name": "timestamp" + }, + { + "type": "double", + "name": "value" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.MGET": { + "summary": "Get the sample with the highest timestamp from each time series matching a specific filter", + "complexity": "O(n) where n is the number of time-series that match the filters", + "arguments": [ + { + "name": "LATEST", + "type": "string", + "optional": true, + "since": "1.8.0" + }, + { + "name": "labels", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "WITHLABELS", + "type": "pure-token", + "token": "WITHLABELS" + }, + { + "type": "block", + "name": "SELECTED_LABELS_BLOCK", + "arguments": [ + { + "name": "SELECTED_LABELS", + "type": "pure-token", + "token": "SELECTED_LABELS" + }, + { + "name": "label1", + "type": "string", + "multiple": true + } + ] + } + ] + }, + { + "name": "filterExpr", + "token": "FILTER", + "type": "oneof", + "arguments": [ + { + "name": "l=v", + "type": "string" + }, + { + "name": "l!=v", + "type": "string" + }, + { + "name": "l=", + "type": "string" + }, + { + "name": "l!=", + "type": "string" + }, + { + "name": "l=(v1,v2,...)", + "type": "string" + }, + { + "name": "l!=(v1,v2,...)", + "type": "string" + } + ], + "multiple": true + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.MRANGE": { + "summary": "Query a range across multiple time series by filters in forward direction", + "complexity": "O(n/m+k) where n = Number of data points, m = Chunk size (data points per chunk), k = Number of data points that are in the requested ranges", + "arguments": [ + { + "name": "fromTimestamp", + "type": "string" + }, + { + "name": "toTimestamp", + "type": "string" + }, + { + "name": "LATEST", + "type": "string", + "optional": true, + "since": "1.8.0" + }, + { + "token": "FILTER_BY_TS", + "name": "Timestamp", + "type": "integer", + "multiple": true, + "optional": true + }, + { + "type": "block", + "name": "fbv", + "optional": true, + "arguments": [ + { + "name": "FILTER_BY_VALUE", + "type": "pure-token", + "token": "FILTER_BY_VALUE" + }, + { + "type": "double", + "name": "min" + }, + { + "type": "double", + "name": "max" + } + ] + }, + { + "name": "labels", + "type": "oneof", + "arguments": [ + { + "name": "WITHLABELS", + "type": "pure-token", + "token": "WITHLABELS" + }, + { + "type": "block", + "name": "SELECTED_LABELS_BLOCK", + "arguments": [ + { + "name": "SELECTED_LABELS", + "type": "pure-token", + "token": "SELECTED_LABELS" + }, + { + "name": "label1", + "type": "string", + "multiple": true + } + ] + } + ], + "optional": true + }, + { + "token": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "aggregation", + "type": "block", + "optional": true, + "arguments": [ + { + "token": "ALIGN", + "name": "value", + "type": "integer", + "optional": true + }, + { + "type": "oneof", + "token": "AGGREGATION", + "name": "aggregator", + "arguments": [ + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "std.p", + "type": "pure-token", + "token": "STD.P" + }, + { + "name": "std.s", + "type": "pure-token", + "token": "STD.S" + }, + { + "name": "var.p", + "type": "pure-token", + "token": "VAR.P" + }, + { + "name": "var.s", + "type": "pure-token", + "token": "VAR.S" + }, + { + "name": "twa", + "type": "pure-token", + "token": "TWA", + "since": "1.8.0" + }, + { + "name": "countnan", + "type": "pure-token", + "token": "COUNTNAN", + "since": "8.6.0" + }, + { + "name": "countall", + "type": "pure-token", + "token": "COUNTALL", + "since": "8.6.0" + } + ] + }, + { + "name": "bucketDuration", + "type": "integer" + }, + { + "name": "buckettimestamp", + "type": "pure-token", + "token": "BUCKETTIMESTAMP", + "optional": true, + "since": "1.8.0" + }, + { + "name": "empty", + "type": "pure-token", + "token": "EMPTY", + "optional": true, + "since": "1.8.0" + } + ] + }, + { + "name": "filterExpr", + "token": "FILTER", + "type": "oneof", + "arguments": [ + { + "name": "l=v", + "type": "string" + }, + { + "name": "l!=v", + "type": "string" + }, + { + "name": "l=", + "type": "string" + }, + { + "name": "l!=", + "type": "string" + }, + { + "name": "l=(v1,v2,...)", + "type": "string" + }, + { + "name": "l!=(v1,v2,...)", + "type": "string" + } + ], + "multiple": true + }, + { + "type": "block", + "name": "groupby", + "optional": true, + "arguments": [ + { + "name": "GROUPBY", + "type": "pure-token", + "token": "GROUPBY" + }, + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "REDUCE" + }, + { + "type": "string", + "name": "reducer" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.MREVRANGE": { + "summary": "Query a range across multiple time-series by filters in reverse direction", + "complexity": "O(n/m+k) where n = Number of data points, m = Chunk size (data points per chunk), k = Number of data points that are in the requested ranges", + "since": "1.4.0", + "arguments": [ + { + "name": "fromTimestamp", + "type": "string" + }, + { + "name": "toTimestamp", + "type": "string" + }, + { + "name": "latest", + "type": "pure-token", + "optional": true, + "token": "LATEST", + "since": "1.8.0" + }, + { + "token": "FILTER_BY_TS", + "name": "Timestamp", + "type": "integer", + "multiple": true, + "optional": true + }, + { + "type": "block", + "name": "fbv", + "optional": true, + "arguments": [ + { + "name": "FILTER_BY_VALUE", + "type": "pure-token", + "token": "FILTER_BY_VALUE" + }, + { + "type": "double", + "name": "min" + }, + { + "type": "double", + "name": "max" + } + ] + }, + { + "name": "labels", + "type": "oneof", + "arguments": [ + { + "name": "WITHLABELS", + "type": "pure-token", + "token": "WITHLABELS" + }, + { + "type": "block", + "name": "SELECTED_LABELS_BLOCK", + "arguments": [ + { + "name": "SELECTED_LABELS", + "type": "pure-token", + "token": "SELECTED_LABELS" + }, + { + "name": "label1", + "type": "string", + "multiple": true + } + ] + } + ], + "optional": true + }, + { + "token": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "aggregation", + "type": "block", + "optional": true, + "arguments": [ + { + "token": "ALIGN", + "name": "value", + "type": "integer", + "optional": true + }, + { + "type": "oneof", + "token": "AGGREGATION", + "name": "aggregator", + "arguments": [ + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "std.p", + "type": "pure-token", + "token": "STD.P" + }, + { + "name": "std.s", + "type": "pure-token", + "token": "STD.S" + }, + { + "name": "var.p", + "type": "pure-token", + "token": "VAR.P" + }, + { + "name": "var.s", + "type": "pure-token", + "token": "VAR.S" + }, + { + "name": "twa", + "type": "pure-token", + "token": "TWA", + "since": "1.8.0" + }, + { + "name": "countnan", + "type": "pure-token", + "token": "COUNTNAN", + "since": "8.6.0" + }, + { + "name": "countall", + "type": "pure-token", + "token": "COUNTALL", + "since": "8.6.0" + } + ] + }, + { + "name": "bucketDuration", + "type": "integer" + }, + { + "name": "buckettimestamp", + "type": "pure-token", + "token": "BUCKETTIMESTAMP", + "optional": true, + "since": "1.8.0" + }, + { + "name": "empty", + "type": "pure-token", + "token": "EMPTY", + "optional": true, + "since": "1.8.0" + } + ] + }, + { + "name": "filterExpr", + "token": "FILTER", + "type": "oneof", + "arguments": [ + { + "name": "l=v", + "type": "string" + }, + { + "name": "l!=v", + "type": "string" + }, + { + "name": "l=", + "type": "string" + }, + { + "name": "l!=", + "type": "string" + }, + { + "name": "l=(v1,v2,...)", + "type": "string" + }, + { + "name": "l!=(v1,v2,...)", + "type": "string" + } + ], + "multiple": true + }, + { + "type": "block", + "name": "groupby", + "optional": true, + "arguments": [ + { + "name": "GROUPBY", + "type": "pure-token", + "token": "GROUPBY" + }, + { + "type": "string", + "name": "label" + }, + { + "type": "string", + "name": "REDUCE" + }, + { + "type": "string", + "name": "reducer" + } + ] + } + ], + "group": "timeseries" + }, + "TS.QUERYINDEX": { + "summary": "Get all time series keys matching a filter list", + "complexity": "O(n) where n is the number of time-series that match the filters", + "arguments": [ + { + "name": "filterExpr", + "type": "oneof", + "arguments": [ + { + "name": "l=v", + "type": "string" + }, + { + "name": "l!=v", + "type": "string" + }, + { + "name": "l=", + "type": "string" + }, + { + "name": "l!=", + "type": "string" + }, + { + "name": "l=(v1,v2,...)", + "type": "string" + }, + { + "name": "l!=(v1,v2,...)", + "type": "string" + } + ], + "multiple": true + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.RANGE": { + "summary": "Query a range in forward direction", + "complexity": "O(n/m+k) where n = Number of data points, m = Chunk size (data points per chunk), k = Number of data points that are in the requested range", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "fromTimestamp", + "type": "string" + }, + { + "name": "toTimestamp", + "type": "string" + }, + { + "name": "LATEST", + "type": "string", + "optional": true, + "since": "1.8.0" + }, + { + "token": "FILTER_BY_TS", + "name": "Timestamp", + "type": "integer", + "multiple": true, + "optional": true + }, + { + "type": "block", + "name": "fbv", + "optional": true, + "arguments": [ + { + "name": "FILTER_BY_VALUE", + "type": "pure-token", + "token": "FILTER_BY_VALUE" + }, + { + "type": "double", + "name": "min" + }, + { + "type": "double", + "name": "max" + } + ] + }, + { + "token": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "aggregation", + "type": "block", + "optional": true, + "arguments": [ + { + "token": "ALIGN", + "name": "value", + "type": "integer", + "optional": true + }, + { + "type": "oneof", + "token": "AGGREGATION", + "name": "aggregator", + "arguments": [ + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "std.p", + "type": "pure-token", + "token": "STD.P" + }, + { + "name": "std.s", + "type": "pure-token", + "token": "STD.S" + }, + { + "name": "var.p", + "type": "pure-token", + "token": "VAR.P" + }, + { + "name": "var.s", + "type": "pure-token", + "token": "VAR.S" + }, + { + "name": "twa", + "type": "pure-token", + "token": "TWA", + "since": "1.8.0" + }, + { + "name": "countnan", + "type": "pure-token", + "token": "COUNTNAN", + "since": "8.6.0" + }, + { + "name": "countall", + "type": "pure-token", + "token": "COUNTALL", + "since": "8.6.0" + } + ] + }, + { + "name": "bucketDuration", + "type": "integer" + }, + { + "name": "buckettimestamp", + "type": "pure-token", + "token": "BUCKETTIMESTAMP", + "optional": true, + "since": "1.8.0" + }, + { + "name": "empty", + "type": "pure-token", + "token": "EMPTY", + "optional": true, + "since": "1.8.0" + } + ] + } + ], + "since": "1.0.0", + "group": "timeseries" + }, + "TS.REVRANGE": { + "summary": "Query a range in reverse direction", + "complexity": "O(n/m+k) where n = Number of data points, m = Chunk size (data points per chunk), k = Number of data points that are in the requested range", + "arguments": [ + { + "name": "key", + "type": "key" + }, + { + "name": "fromTimestamp", + "type": "string" + }, + { + "name": "toTimestamp", + "type": "string" + }, + { + "name": "LATEST", + "type": "string", + "optional": true, + "since": "1.8.0" + }, + { + "token": "FILTER_BY_TS", + "name": "Timestamp", + "type": "integer", + "multiple": true, + "optional": true + }, + { + "type": "block", + "name": "fbv", + "optional": true, + "arguments": [ + { + "name": "FILTER_BY_VALUE", + "type": "pure-token", + "token": "FILTER_BY_VALUE" + }, + { + "type": "double", + "name": "min" + }, + { + "type": "double", + "name": "max" + } + ] + }, + { + "token": "COUNT", + "name": "count", + "type": "integer", + "optional": true + }, + { + "name": "aggregation", + "type": "block", + "optional": true, + "arguments": [ + { + "token": "ALIGN", + "name": "value", + "type": "integer", + "optional": true + }, + { + "type": "oneof", + "token": "AGGREGATION", + "name": "aggregator", + "arguments": [ + { + "name": "avg", + "type": "pure-token", + "token": "AVG" + }, + { + "name": "first", + "type": "pure-token", + "token": "FIRST" + }, + { + "name": "last", + "type": "pure-token", + "token": "LAST" + }, + { + "name": "min", + "type": "pure-token", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "token": "MAX" + }, + { + "name": "sum", + "type": "pure-token", + "token": "SUM" + }, + { + "name": "range", + "type": "pure-token", + "token": "RANGE" + }, + { + "name": "count", + "type": "pure-token", + "token": "COUNT" + }, + { + "name": "std.p", + "type": "pure-token", + "token": "STD.P" + }, + { + "name": "std.s", + "type": "pure-token", + "token": "STD.S" + }, + { + "name": "var.p", + "type": "pure-token", + "token": "VAR.P" + }, + { + "name": "var.s", + "type": "pure-token", + "token": "VAR.S" + }, + { + "name": "twa", + "type": "pure-token", + "token": "TWA", + "since": "1.8.0" + }, + { + "name": "countnan", + "type": "pure-token", + "token": "COUNTNAN", + "since": "8.6.0" + }, + { + "name": "countall", + "type": "pure-token", + "token": "COUNTALL", + "since": "8.6.0" + } + ] + }, + { + "name": "bucketDuration", + "type": "integer" + }, + { + "name": "buckettimestamp", + "type": "oneof", + "token": "BUCKETTIMESTAMP", + "optional": true, + "arguments": [ + { + "name": "-", + "type": "pure-token", + "token": "-" + }, + { + "name": "start", + "type": "pure-token", + "token": "start" + }, + { + "name": "+", + "type": "pure-token", + "token": "+" + }, + { + "name": "end", + "type": "pure-token", + "token": "end" + }, + { + "name": "~", + "type": "pure-token", + "token": "~" + }, + { + "name": "mid", + "type": "pure-token", + "token": "mid" + } + ], + "since": "1.8.0" + }, + { + "name": "empty", + "type": "pure-token", + "token": "EMPTY", + "optional": true, + "since": "1.8.0" + } + ] + } + ], + "since": "1.4.0", + "group": "timeseries" + }, + "TTL": { + "summary": "Returns the expiration time in seconds of a key.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "history": [ + [ + "2.8.0", + "Added the -2 reply." + ] + ], + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "TYPE": { + "summary": "Determines the type of value stored at a key.", + "since": "1.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@keyspace", + "@read", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "UNLINK": { + "summary": "Asynchronously deletes one or more keys.", + "since": "4.0.0", + "group": "generic", + "complexity": "O(1) for each key removed regardless of its size. Then the command does O(N) work in a different thread in order to reclaim memory, where N is the number of allocations the deleted objects where composed of.", + "acl_categories": [ + "@keyspace", + "@write", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RM": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ], + "hints": [ + "request_policy:multi_shard", + "response_policy:agg_sum" + ] + }, + "UNSUBSCRIBE": { + "summary": "Stops listening to messages posted to channels.", + "since": "2.0.0", + "group": "pubsub", + "complexity": "O(N) where N is the number of channels to unsubscribe.", + "acl_categories": [ + "@pubsub", + "@slow" + ], + "arity": -1, + "arguments": [ + { + "name": "channel", + "type": "string", + "display_text": "channel", + "optional": true, + "multiple": true + } + ], + "command_flags": [ + "pubsub", + "noscript", + "loading", + "stale" + ] + }, + "UNWATCH": { + "summary": "Forgets about watched keys of a transaction.", + "since": "2.2.0", + "group": "transactions", + "complexity": "O(1)", + "acl_categories": [ + "@fast", + "@transaction" + ], + "arity": 1, + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "WAIT": { + "summary": "Blocks until the asynchronous replication of all preceding write commands sent by the connection is completed.", + "since": "3.0.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 3, + "arguments": [ + { + "name": "numreplicas", + "type": "integer", + "display_text": "numreplicas" + }, + { + "name": "timeout", + "type": "integer", + "display_text": "timeout" + } + ], + "hints": [ + "request_policy:all_shards", + "response_policy:agg_min" + ] + }, + "WAITAOF": { + "summary": "Blocks until all of the preceding write commands sent by the connection are written to the append-only file of the master and/or replicas.", + "since": "7.2.0", + "group": "generic", + "complexity": "O(1)", + "acl_categories": [ + "@slow", + "@connection" + ], + "arity": 4, + "arguments": [ + { + "name": "numlocal", + "type": "integer", + "display_text": "numlocal" + }, + { + "name": "numreplicas", + "type": "integer", + "display_text": "numreplicas" + }, + { + "name": "timeout", + "type": "integer", + "display_text": "timeout" + } + ], + "command_flags": [ + "noscript" + ], + "hints": [ + "request_policy:all_shards", + "response_policy:agg_min" + ] + }, + "WATCH": { + "summary": "Monitors changes to keys to determine the execution of a transaction.", + "since": "2.2.0", + "group": "transactions", + "complexity": "O(1) for every key.", + "acl_categories": [ + "@fast", + "@transaction" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + } + ], + "command_flags": [ + "noscript", + "loading", + "stale", + "fast", + "allow_busy" + ] + }, + "XACK": { + "summary": "Returns the number of messages that were successfully acknowledged by the consumer group member of a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1) for each message ID processed.", + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "id", + "type": "string", + "display_text": "id", + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "XADD": { + "summary": "Appends a new message to a stream. Creates the key if it doesn't exist.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1) when adding a new entry, O(N) when trimming where N being the number of entries evicted.", + "history": [ + [ + "6.2.0", + "Added the `NOMKSTREAM` option, `MINID` trimming strategy and the `LIMIT` option." + ], + [ + "7.0.0", + "Added support for the `-*` explicit ID form." + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -5, + "key_specs": [ + { + "notes": "UPDATE instead of INSERT because of the optional trimming feature", + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "nomkstream", + "type": "pure-token", + "display_text": "nomkstream", + "token": "NOMKSTREAM", + "since": "6.2.0", + "optional": true + }, + { + "name": "trim", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "strategy", + "type": "oneof", + "arguments": [ + { + "name": "maxlen", + "type": "pure-token", + "display_text": "maxlen", + "token": "MAXLEN" + }, + { + "name": "minid", + "type": "pure-token", + "display_text": "minid", + "token": "MINID", + "since": "6.2.0" + } + ] + }, + { + "name": "operator", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "equal", + "type": "pure-token", + "display_text": "equal", + "token": "=" + }, + { + "name": "approximately", + "type": "pure-token", + "display_text": "approximately", + "token": "~" + } + ] + }, + { + "name": "threshold", + "type": "string", + "display_text": "threshold" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "LIMIT", + "since": "6.2.0", + "optional": true + } + ] + }, + { + "name": "id-selector", + "type": "oneof", + "arguments": [ + { + "name": "auto-id", + "type": "pure-token", + "display_text": "auto-id", + "token": "*" + }, + { + "name": "id", + "type": "string", + "display_text": "id" + } + ] + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "field", + "type": "string", + "display_text": "field" + }, + { + "name": "value", + "type": "string", + "display_text": "value" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "XAUTOCLAIM": { + "summary": "Changes, or acquires, ownership of messages in a consumer group, as if the messages were delivered to as consumer group member.", + "since": "6.2.0", + "group": "stream", + "complexity": "O(1) if COUNT is small.", + "history": [ + [ + "7.0.0", + "Added an element to the reply array, containing deleted entries the command cleared from the PEL" + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer" + }, + { + "name": "min-idle-time", + "type": "string", + "display_text": "min-idle-time" + }, + { + "name": "start", + "type": "string", + "display_text": "start" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + }, + { + "name": "justid", + "type": "pure-token", + "display_text": "justid", + "token": "JUSTID", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "XCLAIM": { + "summary": "Changes, or acquires, ownership of a message in a consumer group, as if the message was delivered a consumer group member.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(log N) with N being the number of messages in the PEL of the consumer group.", + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -6, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer" + }, + { + "name": "min-idle-time", + "type": "string", + "display_text": "min-idle-time" + }, + { + "name": "id", + "type": "string", + "display_text": "id", + "multiple": true + }, + { + "name": "ms", + "type": "integer", + "display_text": "ms", + "token": "IDLE", + "optional": true + }, + { + "name": "unix-time-milliseconds", + "type": "unix-time", + "display_text": "unix-time-milliseconds", + "token": "TIME", + "optional": true + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "RETRYCOUNT", + "optional": true + }, + { + "name": "force", + "type": "pure-token", + "display_text": "force", + "token": "FORCE", + "optional": true + }, + { + "name": "justid", + "type": "pure-token", + "display_text": "justid", + "token": "JUSTID", + "optional": true + }, + { + "name": "lastid", + "type": "string", + "display_text": "lastid", + "token": "LASTID", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "XDEL": { + "summary": "Returns the number of messages after removing them from a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1) for each single item to delete in the stream, regardless of the stream size.", + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "id", + "type": "string", + "display_text": "id", + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "XGROUP": { + "summary": "A container for consumer groups commands.", + "since": "5.0.0", + "group": "stream", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "XGROUP CREATE": { + "summary": "Creates a consumer group.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added the `entries_read` named argument." + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "id-selector", + "type": "oneof", + "arguments": [ + { + "name": "id", + "type": "string", + "display_text": "id" + }, + { + "name": "new-id", + "type": "pure-token", + "display_text": "new-id", + "token": "$" + } + ] + }, + { + "name": "mkstream", + "type": "pure-token", + "display_text": "mkstream", + "token": "MKSTREAM", + "optional": true + }, + { + "name": "entries-read", + "type": "integer", + "display_text": "entries-read", + "token": "ENTRIESREAD", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "XGROUP CREATECONSUMER": { + "summary": "Creates a consumer in a consumer group.", + "since": "6.2.0", + "group": "stream", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": 5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "insert": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer" + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "XGROUP DELCONSUMER": { + "summary": "Deletes a consumer from a consumer group.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": 5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer" + } + ], + "command_flags": [ + "write" + ] + }, + "XGROUP DESTROY": { + "summary": "Destroys a consumer group.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(N) where N is the number of entries in the group's pending entries list (PEL).", + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + } + ], + "command_flags": [ + "write" + ] + }, + "XGROUP HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "acl_categories": [ + "@stream", + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "XGROUP SETID": { + "summary": "Sets the last-delivered ID of a consumer group.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added the optional `entries_read` argument." + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "id-selector", + "type": "oneof", + "arguments": [ + { + "name": "id", + "type": "string", + "display_text": "id" + }, + { + "name": "new-id", + "type": "pure-token", + "display_text": "new-id", + "token": "$" + } + ] + }, + { + "name": "entriesread", + "type": "integer", + "display_text": "entries-read", + "token": "ENTRIESREAD", + "optional": true + } + ], + "command_flags": [ + "write" + ] + }, + "XINFO": { + "summary": "A container for stream introspection commands.", + "since": "5.0.0", + "group": "stream", + "complexity": "Depends on subcommand.", + "acl_categories": [ + "@slow" + ], + "arity": -2 + }, + "XINFO CONSUMERS": { + "summary": "Returns a list of the consumers in a consumer group.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "7.2.0", + "Added the `inactive` field." + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "XINFO GROUPS": { + "summary": "Returns a list of the consumer groups of a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added the `entries-read` and `lag` fields" + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly" + ] + }, + "XINFO HELP": { + "summary": "Returns helpful text about the different subcommands.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "acl_categories": [ + "@stream", + "@slow" + ], + "arity": 2, + "command_flags": [ + "loading", + "stale" + ] + }, + "XINFO STREAM": { + "summary": "Returns information about a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "6.0.0", + "Added the `FULL` modifier." + ], + [ + "7.0.0", + "Added the `max-deleted-entry-id`, `entries-added`, `recorded-first-entry-id`, `entries-read` and `lag` fields" + ], + [ + "7.2.0", + "Added the `active-time` field, and changed the meaning of `seen-time`." + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "full-block", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "full", + "type": "pure-token", + "display_text": "full", + "token": "FULL" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ] + } + ], + "command_flags": [ + "readonly" + ] + }, + "XLEN": { + "summary": "Return the number of messages in a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@stream", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "XPENDING": { + "summary": "Returns the information and entries from a stream consumer group's pending entries list.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(N) with N being the number of elements returned, so asking for a small fixed number of entries per call is O(1). O(M), where M is the total number of entries scanned when used with the IDLE filter. When the command returns just the summary and the list of consumers is small, it runs in O(1) time; otherwise, an additional O(N) time for iterating every consumer.", + "history": [ + [ + "6.2.0", + "Added the `IDLE` option and exclusive range intervals." + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "filters", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "min-idle-time", + "type": "integer", + "display_text": "min-idle-time", + "token": "IDLE", + "since": "6.2.0", + "optional": true + }, + { + "name": "start", + "type": "string", + "display_text": "start" + }, + { + "name": "end", + "type": "string", + "display_text": "end" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer", + "optional": true + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "XRANGE": { + "summary": "Returns the messages from a stream within a range of IDs.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(N) with N being the number of elements being returned. If N is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1).", + "history": [ + [ + "6.2.0", + "Added exclusive ranges." + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "string", + "display_text": "start" + }, + { + "name": "end", + "type": "string", + "display_text": "end" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "XREAD": { + "summary": "Returns messages from multiple streams with IDs greater than the ones requested. Blocks until a message is available otherwise.", + "since": "5.0.0", + "group": "stream", + "acl_categories": [ + "@read", + "@stream", + "@slow", + "@blocking" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STREAMS", + "startfrom": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 2 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds", + "token": "BLOCK", + "optional": true + }, + { + "name": "streams", + "type": "block", + "token": "STREAMS", + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "id", + "type": "string", + "display_text": "id", + "multiple": true + } + ] + } + ], + "command_flags": [ + "readonly", + "blocking", + "movablekeys" + ] + }, + "XREADGROUP": { + "summary": "Returns new or historical messages from a stream for a consumer in a group. Blocks until a message is available otherwise.", + "since": "5.0.0", + "group": "stream", + "complexity": "For each stream mentioned: O(M) with M being the number of elements returned. If M is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1). On the other side when XREADGROUP blocks, XADD will pay the O(N) time in order to serve the N clients blocked on the stream getting new data.", + "acl_categories": [ + "@write", + "@stream", + "@slow", + "@blocking" + ], + "arity": -7, + "key_specs": [ + { + "begin_search": { + "type": "keyword", + "spec": { + "keyword": "STREAMS", + "startfrom": 4 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": -1, + "keystep": 1, + "limit": 2 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "group-block", + "type": "block", + "token": "GROUP", + "arguments": [ + { + "name": "group", + "type": "string", + "display_text": "group" + }, + { + "name": "consumer", + "type": "string", + "display_text": "consumer" + } + ] + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + }, + { + "name": "milliseconds", + "type": "integer", + "display_text": "milliseconds", + "token": "BLOCK", + "optional": true + }, + { + "name": "noack", + "type": "pure-token", + "display_text": "noack", + "token": "NOACK", + "optional": true + }, + { + "name": "streams", + "type": "block", + "token": "STREAMS", + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "id", + "type": "string", + "display_text": "id", + "multiple": true + } + ] + } + ], + "command_flags": [ + "write", + "blocking", + "movablekeys" + ] + }, + "XREVRANGE": { + "summary": "Returns the messages from a stream within a range of IDs in reverse order.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(N) with N being the number of elements returned. If N is constant (e.g. always asking for the first 10 elements with COUNT), you can consider it O(1).", + "history": [ + [ + "6.2.0", + "Added exclusive ranges." + ] + ], + "acl_categories": [ + "@read", + "@stream", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "end", + "type": "string", + "display_text": "end" + }, + { + "name": "start", + "type": "string", + "display_text": "start" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "XSETID": { + "summary": "An internal command for replicating stream values.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(1)", + "history": [ + [ + "7.0.0", + "Added the `entries_added` and `max_deleted_entry_id` arguments." + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "last-id", + "type": "string", + "display_text": "last-id" + }, + { + "name": "entries-added", + "type": "integer", + "display_text": "entries-added", + "token": "ENTRIESADDED", + "since": "7.0.0", + "optional": true + }, + { + "name": "max-deleted-id", + "type": "string", + "display_text": "max-deleted-id", + "token": "MAXDELETEDID", + "since": "7.0.0", + "optional": true + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "XTRIM": { + "summary": "Deletes messages from the beginning of a stream.", + "since": "5.0.0", + "group": "stream", + "complexity": "O(N), with N being the number of evicted entries. Constant times are very small however, since entries are organized in macro nodes containing multiple entries that can be released with a single deallocation.", + "history": [ + [ + "6.2.0", + "Added the `MINID` trimming strategy and the `LIMIT` option." + ] + ], + "acl_categories": [ + "@write", + "@stream", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "trim", + "type": "block", + "arguments": [ + { + "name": "strategy", + "type": "oneof", + "arguments": [ + { + "name": "maxlen", + "type": "pure-token", + "display_text": "maxlen", + "token": "MAXLEN" + }, + { + "name": "minid", + "type": "pure-token", + "display_text": "minid", + "token": "MINID", + "since": "6.2.0" + } + ] + }, + { + "name": "operator", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "equal", + "type": "pure-token", + "display_text": "equal", + "token": "=" + }, + { + "name": "approximately", + "type": "pure-token", + "display_text": "approximately", + "token": "~" + } + ] + }, + { + "name": "threshold", + "type": "string", + "display_text": "threshold" + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "LIMIT", + "since": "6.2.0", + "optional": true + } + ] + } + ], + "command_flags": [ + "write" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "ZADD": { + "summary": "Adds one or more members to a sorted set, or updates their scores. Creates the key if it doesn't exist.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(log(N)) for each item added, where N is the number of elements in the sorted set.", + "history": [ + [ + "2.4.0", + "Accepts multiple elements." + ], + [ + "3.0.2", + "Added the `XX`, `NX`, `CH` and `INCR` options." + ], + [ + "6.2.0", + "Added the `GT` and `LT` options." + ] + ], + "acl_categories": [ + "@write", + "@sortedset", + "@fast" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "condition", + "type": "oneof", + "since": "3.0.2", + "optional": true, + "arguments": [ + { + "name": "nx", + "type": "pure-token", + "display_text": "nx", + "token": "NX" + }, + { + "name": "xx", + "type": "pure-token", + "display_text": "xx", + "token": "XX" + } + ] + }, + { + "name": "comparison", + "type": "oneof", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "gt", + "type": "pure-token", + "display_text": "gt", + "token": "GT" + }, + { + "name": "lt", + "type": "pure-token", + "display_text": "lt", + "token": "LT" + } + ] + }, + { + "name": "change", + "type": "pure-token", + "display_text": "change", + "token": "CH", + "since": "3.0.2", + "optional": true + }, + { + "name": "increment", + "type": "pure-token", + "display_text": "increment", + "token": "INCR", + "since": "3.0.2", + "optional": true + }, + { + "name": "data", + "type": "block", + "multiple": true, + "arguments": [ + { + "name": "score", + "type": "double", + "display_text": "score" + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "ZCARD": { + "summary": "Returns the number of members in a sorted set.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": 2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZCOUNT": { + "summary": "Returns the count of members in a sorted set that have scores within a range.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "double", + "display_text": "min" + }, + { + "name": "max", + "type": "double", + "display_text": "max" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZDIFF": { + "summary": "Returns the difference between multiple sorted sets.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(L + (N-K)log(N)) worst case where L is the total number of elements in all the sets, N is the size of the first set, and K is the size of the result set.", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "ZDIFFSTORE": { + "summary": "Stores the difference of multiple sorted sets in a key.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(L + (N-K)log(N)) worst case where L is the total number of elements in all the sets, N is the size of the first set, and K is the size of the result set.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "ZINCRBY": { + "summary": "Increments the score of a member in a sorted set.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(log(N)) where N is the number of elements in the sorted set.", + "acl_categories": [ + "@write", + "@sortedset", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "update": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "increment", + "type": "integer", + "display_text": "increment" + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ], + "command_flags": [ + "write", + "denyoom", + "fast" + ] + }, + "ZINTER": { + "summary": "Returns the intersect of multiple sorted sets.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(N*K)+O(M*log(M)) worst case with N being the smallest input sorted set, K being the number of input sorted sets and M being the number of elements in the resulting sorted set.", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "weight", + "type": "integer", + "display_text": "weight", + "token": "WEIGHTS", + "optional": true, + "multiple": true + }, + { + "name": "aggregate", + "type": "oneof", + "token": "AGGREGATE", + "optional": true, + "arguments": [ + { + "name": "sum", + "type": "pure-token", + "display_text": "sum", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "ZINTERCARD": { + "summary": "Returns the number of members of the intersect of multiple sorted sets.", + "since": "7.0.0", + "group": "sorted-set", + "complexity": "O(N*K) worst case with N being the smallest input sorted set, K being the number of input sorted sets.", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "limit", + "type": "integer", + "display_text": "limit", + "token": "LIMIT", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "ZINTERSTORE": { + "summary": "Stores the intersect of multiple sorted sets in a key.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(N*K)+O(M*log(M)) worst case with N being the smallest input sorted set, K being the number of input sorted sets and M being the number of elements in the resulting sorted set.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + }, + { + "name": "weight", + "type": "integer", + "display_text": "weight", + "token": "WEIGHTS", + "optional": true, + "multiple": true + }, + { + "name": "aggregate", + "type": "oneof", + "token": "AGGREGATE", + "optional": true, + "arguments": [ + { + "name": "sum", + "type": "pure-token", + "display_text": "sum", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ] + }, + "ZLEXCOUNT": { + "summary": "Returns the number of members in a sorted set within a lexicographical range.", + "since": "2.8.9", + "group": "sorted-set", + "complexity": "O(log(N)) with N being the number of elements in the sorted set.", + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "string", + "display_text": "min" + }, + { + "name": "max", + "type": "string", + "display_text": "max" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZMPOP": { + "summary": "Returns the highest- or lowest-scoring members from one or more sorted sets after removing them. Deletes the sorted set if the last member was popped.", + "since": "7.0.0", + "group": "sorted-set", + "complexity": "O(K) + O(M*log(N)) where K is the number of provided keys, N being the number of elements in the sorted set, and M being the number of elements popped.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "where", + "type": "oneof", + "arguments": [ + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "write", + "movablekeys" + ] + }, + "ZMSCORE": { + "summary": "Returns the score of one or more members in a sorted set.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(N) where N is the number of members being requested.", + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "multiple": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZPOPMAX": { + "summary": "Returns the highest-scoring members from a sorted set after removing them. Deletes the sorted set if the last member was popped.", + "since": "5.0.0", + "group": "sorted-set", + "complexity": "O(log(N)*M) with N being the number of elements in the sorted set, and M being the number of elements popped.", + "acl_categories": [ + "@write", + "@sortedset", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "ZPOPMIN": { + "summary": "Returns the lowest-scoring members from a sorted set after removing them. Deletes the sorted set if the last member was popped.", + "since": "5.0.0", + "group": "sorted-set", + "complexity": "O(log(N)*M) with N being the number of elements in the sorted set, and M being the number of elements popped.", + "acl_categories": [ + "@write", + "@sortedset", + "@fast" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "access": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "optional": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "ZRANDMEMBER": { + "summary": "Returns one or more random members from a sorted set.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(N) where N is the number of members returned", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -2, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "options", + "type": "block", + "optional": true, + "arguments": [ + { + "name": "count", + "type": "integer", + "display_text": "count" + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "ZRANGE": { + "summary": "Returns members in a sorted set within a range of indexes.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements returned.", + "history": [ + [ + "6.2.0", + "Added the `REV`, `BYSCORE`, `BYLEX` and `LIMIT` options." + ] + ], + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "string", + "display_text": "start" + }, + { + "name": "stop", + "type": "string", + "display_text": "stop" + }, + { + "name": "sortby", + "type": "oneof", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "byscore", + "type": "pure-token", + "display_text": "byscore", + "token": "BYSCORE" + }, + { + "name": "bylex", + "type": "pure-token", + "display_text": "bylex", + "token": "BYLEX" + } + ] + }, + { + "name": "rev", + "type": "pure-token", + "display_text": "rev", + "token": "REV", + "since": "6.2.0", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "since": "6.2.0", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ], + "command_flags": [ + "readonly" + ] + }, + "ZRANGEBYLEX": { + "summary": "Returns members in a sorted set within a lexicographical range.", + "since": "2.8.9", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "deprecated_since": "6.2.0", + "replaced_by": "`ZRANGE` with the `BYLEX` argument", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "string", + "display_text": "min" + }, + { + "name": "max", + "type": "string", + "display_text": "max" + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "ZRANGEBYSCORE": { + "summary": "Returns members in a sorted set within a range of scores.", + "since": "1.0.5", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "deprecated_since": "6.2.0", + "replaced_by": "`ZRANGE` with the `BYSCORE` argument", + "history": [ + [ + "2.0.0", + "Added the `WITHSCORES` modifier." + ] + ], + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "double", + "display_text": "min" + }, + { + "name": "max", + "type": "double", + "display_text": "max" + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "since": "2.0.0", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "ZRANGESTORE": { + "summary": "Stores a range of members from sorted set in a key.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements stored into the destination key.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": -5, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "dst", + "type": "key", + "display_text": "dst", + "key_spec_index": 0 + }, + { + "name": "src", + "type": "key", + "display_text": "src", + "key_spec_index": 1 + }, + { + "name": "min", + "type": "string", + "display_text": "min" + }, + { + "name": "max", + "type": "string", + "display_text": "max" + }, + { + "name": "sortby", + "type": "oneof", + "optional": true, + "arguments": [ + { + "name": "byscore", + "type": "pure-token", + "display_text": "byscore", + "token": "BYSCORE" + }, + { + "name": "bylex", + "type": "pure-token", + "display_text": "bylex", + "token": "BYLEX" + } + ] + }, + { + "name": "rev", + "type": "pure-token", + "display_text": "rev", + "token": "REV", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom" + ] + }, + "ZRANK": { + "summary": "Returns the index of a member in a sorted set ordered by ascending scores.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(log(N))", + "history": [ + [ + "7.2.0", + "Added the optional `WITHSCORE` argument." + ] + ], + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + }, + { + "name": "withscore", + "type": "pure-token", + "display_text": "withscore", + "token": "WITHSCORE", + "optional": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZREM": { + "summary": "Removes one or more members from a sorted set. Deletes the sorted set if all members were removed.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(M*log(N)) with N being the number of elements in the sorted set and M the number of elements to be removed.", + "history": [ + [ + "2.4.0", + "Accepts multiple elements." + ] + ], + "acl_categories": [ + "@write", + "@sortedset", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member", + "multiple": true + } + ], + "command_flags": [ + "write", + "fast" + ] + }, + "ZREMRANGEBYLEX": { + "summary": "Removes members in a sorted set within a lexicographical range. Deletes the sorted set if all members were removed.", + "since": "2.8.9", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "string", + "display_text": "min" + }, + { + "name": "max", + "type": "string", + "display_text": "max" + } + ], + "command_flags": [ + "write" + ] + }, + "ZREMRANGEBYRANK": { + "summary": "Removes members in a sorted set within a range of indexes. Deletes the sorted set if all members were removed.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "stop", + "type": "integer", + "display_text": "stop" + } + ], + "command_flags": [ + "write" + ] + }, + "ZREMRANGEBYSCORE": { + "summary": "Removes members in a sorted set within a range of scores. Deletes the sorted set if all members were removed.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements removed by the operation.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": 4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RW": true, + "delete": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "min", + "type": "double", + "display_text": "min" + }, + { + "name": "max", + "type": "double", + "display_text": "max" + } + ], + "command_flags": [ + "write" + ] + }, + "ZREVRANGE": { + "summary": "Returns members in a sorted set within a range of indexes in reverse order.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements returned.", + "deprecated_since": "6.2.0", + "replaced_by": "`ZRANGE` with the `REV` argument", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "start", + "type": "integer", + "display_text": "start" + }, + { + "name": "stop", + "type": "integer", + "display_text": "stop" + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "ZREVRANGEBYLEX": { + "summary": "Returns members in a sorted set within a lexicographical range in reverse order.", + "since": "2.8.9", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "deprecated_since": "6.2.0", + "replaced_by": "`ZRANGE` with the `REV` and `BYLEX` arguments", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "max", + "type": "string", + "display_text": "max" + }, + { + "name": "min", + "type": "string", + "display_text": "min" + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "ZREVRANGEBYSCORE": { + "summary": "Returns members in a sorted set within a range of scores in reverse order.", + "since": "2.2.0", + "group": "sorted-set", + "complexity": "O(log(N)+M) with N being the number of elements in the sorted set and M the number of elements being returned. If M is constant (e.g. always asking for the first 10 elements with LIMIT), you can consider it O(log(N)).", + "deprecated_since": "6.2.0", + "replaced_by": "`ZRANGE` with the `REV` and `BYSCORE` arguments", + "history": [ + [ + "2.1.6", + "`min` and `max` can be exclusive." + ] + ], + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "max", + "type": "double", + "display_text": "max" + }, + { + "name": "min", + "type": "double", + "display_text": "min" + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + }, + { + "name": "limit", + "type": "block", + "token": "LIMIT", + "optional": true, + "arguments": [ + { + "name": "offset", + "type": "integer", + "display_text": "offset" + }, + { + "name": "count", + "type": "integer", + "display_text": "count" + } + ] + } + ], + "command_flags": [ + "readonly" + ], + "doc_flags": [ + "deprecated" + ] + }, + "ZREVRANK": { + "summary": "Returns the index of a member in a sorted set ordered by descending scores.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(log(N))", + "history": [ + [ + "7.2.0", + "Added the optional `WITHSCORE` argument." + ] + ], + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + }, + { + "name": "withscore", + "type": "pure-token", + "display_text": "withscore", + "token": "WITHSCORE", + "optional": true + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZSCAN": { + "summary": "Iterates over members and scores of a sorted set.", + "since": "2.8.0", + "group": "sorted-set", + "complexity": "O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "cursor", + "type": "integer", + "display_text": "cursor" + }, + { + "name": "pattern", + "type": "pattern", + "display_text": "pattern", + "token": "MATCH", + "optional": true + }, + { + "name": "count", + "type": "integer", + "display_text": "count", + "token": "COUNT", + "optional": true + } + ], + "command_flags": [ + "readonly" + ], + "hints": [ + "nondeterministic_output" + ] + }, + "ZSCORE": { + "summary": "Returns the score of a member in a sorted set.", + "since": "1.2.0", + "group": "sorted-set", + "complexity": "O(1)", + "acl_categories": [ + "@read", + "@sortedset", + "@fast" + ], + "arity": 3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0 + }, + { + "name": "member", + "type": "string", + "display_text": "member" + } + ], + "command_flags": [ + "readonly", + "fast" + ] + }, + "ZUNION": { + "summary": "Returns the union of multiple sorted sets.", + "since": "6.2.0", + "group": "sorted-set", + "complexity": "O(N)+O(M*log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "acl_categories": [ + "@read", + "@sortedset", + "@slow" + ], + "arity": -3, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 0, + "multiple": true + }, + { + "name": "weight", + "type": "integer", + "display_text": "weight", + "token": "WEIGHTS", + "optional": true, + "multiple": true + }, + { + "name": "aggregate", + "type": "oneof", + "token": "AGGREGATE", + "optional": true, + "arguments": [ + { + "name": "sum", + "type": "pure-token", + "display_text": "sum", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + }, + { + "name": "withscores", + "type": "pure-token", + "display_text": "withscores", + "token": "WITHSCORES", + "optional": true + } + ], + "command_flags": [ + "readonly", + "movablekeys" + ] + }, + "ZUNIONSTORE": { + "summary": "Stores the union of multiple sorted sets in a key.", + "since": "2.0.0", + "group": "sorted-set", + "complexity": "O(N)+O(M log(M)) with N being the sum of the sizes of the input sorted sets, and M being the number of elements in the resulting sorted set.", + "acl_categories": [ + "@write", + "@sortedset", + "@slow" + ], + "arity": -4, + "key_specs": [ + { + "begin_search": { + "type": "index", + "spec": { + "index": 1 + } + }, + "find_keys": { + "type": "range", + "spec": { + "lastkey": 0, + "keystep": 1, + "limit": 0 + } + }, + "OW": true, + "update": true + }, + { + "begin_search": { + "type": "index", + "spec": { + "index": 2 + } + }, + "find_keys": { + "type": "keynum", + "spec": { + "keynumidx": 0, + "firstkey": 1, + "keystep": 1 + } + }, + "RO": true, + "access": true + } + ], + "arguments": [ + { + "name": "destination", + "type": "key", + "display_text": "destination", + "key_spec_index": 0 + }, + { + "name": "numkeys", + "type": "integer", + "display_text": "numkeys" + }, + { + "name": "key", + "type": "key", + "display_text": "key", + "key_spec_index": 1, + "multiple": true + }, + { + "name": "weight", + "type": "integer", + "display_text": "weight", + "token": "WEIGHTS", + "optional": true, + "multiple": true + }, + { + "name": "aggregate", + "type": "oneof", + "token": "AGGREGATE", + "optional": true, + "arguments": [ + { + "name": "sum", + "type": "pure-token", + "display_text": "sum", + "token": "SUM" + }, + { + "name": "min", + "type": "pure-token", + "display_text": "min", + "token": "MIN" + }, + { + "name": "max", + "type": "pure-token", + "display_text": "max", + "token": "MAX" + } + ] + } + ], + "command_flags": [ + "write", + "denyoom", + "movablekeys" + ] + } + } +} diff --git a/frontend/src/modules/redis/common.test.ts b/frontend/src/modules/redis/common.test.ts new file mode 100644 index 0000000..2646d1c --- /dev/null +++ b/frontend/src/modules/redis/common.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { buildRedisPreview, redisFullStatementForType } from './common' + +describe('buildRedisPreview', () => { + it('renders string preview values', () => { + const preview = { + kind: 'string', + limit: 20, + value: 'hello', + truncated: false, + } + + const result = buildRedisPreview(preview) + + expect(result.kind).toBe('string') + expect(result.rows).toEqual([['hello']]) + expect(result.truncated).toBe(false) + }) + + it('propagates truncated flag', () => { + const preview = { + kind: 'string', + limit: 20, + value: 'hello', + truncated: true, + } + + const result = buildRedisPreview(preview) + + expect(result.truncated).toBe(true) + }) + + it('builds full statements for list', () => { + expect(redisFullStatementForType('items', 'list')).toBe('LRANGE items 0 -1') + }) +}) diff --git a/frontend/src/modules/redis/common.ts b/frontend/src/modules/redis/common.ts new file mode 100644 index 0000000..260b99a --- /dev/null +++ b/frontend/src/modules/redis/common.ts @@ -0,0 +1,215 @@ +export function redisStatementForType(name: string, keyType: string) { + switch (keyType) { + case 'hash': + return `HGETALL ${name}` + case 'list': + return `LRANGE ${name} 0 20` + case 'set': + return `SMEMBERS ${name}` + case 'zset': + return `ZRANGE ${name} 0 20 WITHSCORES` + case 'stream': + return `XRANGE ${name} - + COUNT 20` + case 'string': + default: + return `GET ${name}` + } +} + +export function redisFullStatementForType(name: string, keyType: string) { + switch (keyType) { + case 'hash': + return `HGETALL ${name}` + case 'list': + return `LRANGE ${name} 0 -1` + case 'set': + return `SMEMBERS ${name}` + case 'zset': + return `ZRANGE ${name} 0 -1 WITHSCORES` + case 'stream': + return `XRANGE ${name} - +` + case 'string': + default: + return `GET ${name}` + } +} + +export function stringifyPreviewValue(value: any) { + if (value === undefined || value === null) { + return '-' + } + if (typeof value === 'string') { + return value + } + return JSON.stringify(value) +} + +export function buildRedisPreview(preview: any) { + const kind = preview?.kind || 'key' + const limit = preview?.limit || 20 + const truncated = Boolean(preview?.truncated) + const binary = Boolean(preview?.binary) + const valueB64 = typeof preview?.valueB64 === 'string' ? preview.valueB64 : '' + const valueB64Truncated = Boolean(preview?.valueB64Truncated) + if (kind === 'string') { + const value = preview?.value + const rows = value === undefined ? [] : [[stringifyPreviewValue(value)]] + return { kind, limit, headers: ['Value'], rows, truncated, binary, valueB64, valueB64Truncated } + } + const items = Array.isArray(preview?.items) ? preview.items : [] + + if (!items.length) { + return { kind, limit, headers: [], rows: [], truncated } + } + + let headers = ['Value'] + let rows = items.map((item: any) => [stringifyPreviewValue(item.value)]) + + if (kind === 'hash') { + headers = ['Field', 'Value'] + rows = items.map((item: any) => [item.field ?? '-', stringifyPreviewValue(item.value)]) + } else if (kind === 'list') { + headers = ['Index', 'Value'] + rows = items.map((item: any) => [String(item.index ?? '-'), stringifyPreviewValue(item.value)]) + } else if (kind === 'set') { + headers = ['Member'] + rows = items.map((item: any) => [stringifyPreviewValue(item.value)]) + } else if (kind === 'zset') { + headers = ['Member', 'Score'] + rows = items.map((item: any) => [stringifyPreviewValue(item.value), String(item.score ?? '-')]) + } else if (kind === 'stream') { + headers = ['ID', 'Fields'] + rows = items.map((item: any) => [item.id ?? '-', stringifyPreviewValue(item.fields)]) + } + + return { kind, limit, headers, rows, truncated } +} + +export type RedisFullView = { + kind: string + headers: string[] + rows: string[][] + isEmpty: boolean + raw: any +} + +const EMPTY_VIEW = (kind: string): RedisFullView => ({ + kind, + headers: [], + rows: [], + isEmpty: true, + raw: null, +}) + +const isEmptyRaw = (raw: any): boolean => { + if (raw === null || raw === undefined) return true + if (typeof raw === 'string') return raw.length === 0 + if (Array.isArray(raw)) return raw.length === 0 + if (typeof raw === 'object') return Object.keys(raw).length === 0 + return false +} + +// Build structured rows from the raw "full value" payload returned by the +// backend (HGETALL / LRANGE 0 -1 / SMEMBERS / ZRANGE WITHSCORES / XRANGE / GET). +// Always returns a typed view (never a `{}` blob). +export function buildRedisFullView(rawValue: any, kind: string): RedisFullView { + const k = String(kind || 'string') + if (isEmptyRaw(rawValue)) return EMPTY_VIEW(k) + + if (k === 'string') { + const value = typeof rawValue === 'string' ? rawValue : stringifyPreviewValue(rawValue) + if (!value) return EMPTY_VIEW(k) + return { kind: k, headers: ['Value'], rows: [[value]], isEmpty: false, raw: rawValue } + } + + if (k === 'hash') { + // raw is a {field: value} object, or array of [field, value] pairs + const rows: string[][] = [] + if (Array.isArray(rawValue)) { + for (let i = 0; i < rawValue.length; i += 2) { + rows.push([String(rawValue[i] ?? '-'), stringifyPreviewValue(rawValue[i + 1])]) + } + } else if (rawValue && typeof rawValue === 'object') { + for (const [field, val] of Object.entries(rawValue)) { + rows.push([field, stringifyPreviewValue(val)]) + } + } + if (!rows.length) return EMPTY_VIEW(k) + return { kind: k, headers: ['Field', 'Value'], rows, isEmpty: false, raw: rawValue } + } + + if (k === 'list') { + const items = Array.isArray(rawValue) ? rawValue : [] + if (!items.length) return EMPTY_VIEW(k) + const rows = items.map((v, idx) => [String(idx), stringifyPreviewValue(v)]) + return { kind: k, headers: ['Index', 'Value'], rows, isEmpty: false, raw: rawValue } + } + + if (k === 'set') { + const items = Array.isArray(rawValue) ? rawValue : [] + if (!items.length) return EMPTY_VIEW(k) + const rows = items.map((v) => [stringifyPreviewValue(v)]) + return { kind: k, headers: ['Member'], rows, isEmpty: false, raw: rawValue } + } + + if (k === 'zset') { + // raw shapes seen: [{member, score}], or flat ["m", "s", ...], or {member: score} + const rows: string[][] = [] + if (Array.isArray(rawValue)) { + const looksLikeFlat = rawValue.every((v) => typeof v !== 'object' || v === null) + if (looksLikeFlat) { + for (let i = 0; i < rawValue.length; i += 2) { + rows.push([stringifyPreviewValue(rawValue[i]), String(rawValue[i + 1] ?? '-')]) + } + } else { + for (const item of rawValue) { + if (!item || typeof item !== 'object') continue + const member = (item as any).member ?? (item as any).value ?? '-' + const score = (item as any).score ?? '-' + rows.push([stringifyPreviewValue(member), String(score)]) + } + } + } else if (rawValue && typeof rawValue === 'object') { + for (const [member, score] of Object.entries(rawValue)) { + rows.push([member, String(score ?? '-')]) + } + } + if (!rows.length) return EMPTY_VIEW(k) + return { kind: k, headers: ['Member', 'Score'], rows, isEmpty: false, raw: rawValue } + } + + if (k === 'stream') { + // Two shapes are possible: + // 1. Preview path normalises XRANGE into [{id, fields}] objects. + // 2. Full-fetch path goes through client.Do("XRANGE", ...) which returns the + // raw RESP shape [[id, [field, value, ...]], ...]. Object access on those + // tuples yields undefined and used to render as "-" / raw nested array. + const items = Array.isArray(rawValue) ? rawValue : [] + if (!items.length) return EMPTY_VIEW(k) + const rows = items.map((item: any) => { + if (Array.isArray(item)) { + const id = item[0] + const fields = item[1] + const fieldsObj: Record = {} + if (Array.isArray(fields)) { + for (let i = 0; i < fields.length; i += 2) { + fieldsObj[String(fields[i] ?? '')] = fields[i + 1] + } + return [String(id ?? '-'), stringifyPreviewValue(fieldsObj)] + } + return [String(id ?? '-'), stringifyPreviewValue(fields ?? item)] + } + return [String(item?.id ?? '-'), stringifyPreviewValue(item?.fields ?? item)] + }) + return { kind: k, headers: ['ID', 'Fields'], rows, isEmpty: false, raw: rawValue } + } + + // Unknown kind — render a single value cell rather than {}. + return { + kind: k, + headers: ['Value'], + rows: [[stringifyPreviewValue(rawValue)]], + isEmpty: false, + raw: rawValue, + } +} diff --git a/frontend/src/modules/redis/protobuf-autodetect.test.ts b/frontend/src/modules/redis/protobuf-autodetect.test.ts new file mode 100644 index 0000000..cd4aa5f --- /dev/null +++ b/frontend/src/modules/redis/protobuf-autodetect.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it } from 'vitest' +import protobuf from 'protobufjs' + +import { + AUTO_DETECT_MAX_BYTES, + autoDetectMessage, + clearAutoDetectCache, + clearProtobufRootCache, + isLikelyProtobuf, +} from './protobuf' + +const userSchema = ` +syntax = "proto3"; +message User { + string name = 1; + int32 age = 2; +} +` + +const orderSchema = ` +syntax = "proto3"; +message Order { + string order_id = 1; + int64 total_cents = 2; + string customer_email = 3; +} +` + +const buildBytes = (schema: string, typeName: string, payload: Record): Uint8Array => { + const root = protobuf.parse(schema, { keepCase: true }).root + const type = root.lookupType(typeName) + return type.encode(type.create(payload)).finish() +} + +const toBase64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64') + +afterEach(() => { + clearAutoDetectCache() + clearProtobufRootCache() +}) + +describe('isLikelyProtobuf', () => { + it('accepts well-formed protobuf bytes', () => { + const bytes = buildBytes(userSchema, 'User', { name: 'Alice', age: 30 }) + expect(isLikelyProtobuf(bytes)).toBe(true) + }) + + it('rejects empty buffers', () => { + expect(isLikelyProtobuf(new Uint8Array())).toBe(false) + }) + + it('rejects plain ASCII text', () => { + expect(isLikelyProtobuf(new TextEncoder().encode('hello world'))).toBe(false) + }) + + it('rejects JSON-shaped bytes', () => { + expect(isLikelyProtobuf(new TextEncoder().encode('{"a":1}'))).toBe(false) + }) + + it('rejects buffers with leading zero tag', () => { + expect(isLikelyProtobuf(new Uint8Array([0x00, 0x01, 0x02]))).toBe(false) + }) + + it('rejects truncated length-delimited fields', () => { + expect(isLikelyProtobuf(new Uint8Array([0x0a, 0x05, 0x61]))).toBe(false) + }) +}) + +describe('autoDetectMessage', () => { + it('returns null when no schemas are provided', () => { + const bytes = buildBytes(userSchema, 'User', { name: 'Bob', age: 25 }) + expect(autoDetectMessage(toBase64(bytes), [])).toBeNull() + }) + + it('picks the round-trip-matching message type with high confidence', () => { + const bytes = buildBytes(userSchema, 'User', { name: 'Alice', age: 30 }) + const result = autoDetectMessage(toBase64(bytes), [ + { schemaId: 'rps_user', schemaName: 'user.proto', content: userSchema }, + { schemaId: 'rps_order', schemaName: 'order.proto', content: orderSchema }, + ]) + expect(result).not.toBeNull() + expect(result!.schemaId).toBe('rps_user') + expect(result!.messageType).toBe('User') + expect(result!.confidence).toBe('high') + }) + + it('returns null for plain text values', () => { + const result = autoDetectMessage('hello world', [ + { schemaId: 'rps_user', schemaName: 'user.proto', content: userSchema }, + ]) + expect(result).toBeNull() + }) + + it('returns null for values exceeding the size budget', () => { + const big = Buffer.alloc(AUTO_DETECT_MAX_BYTES + 1, 0x0a).toString('base64') + const result = autoDetectMessage(big, [ + { schemaId: 'rps_user', schemaName: 'user.proto', content: userSchema }, + ]) + expect(result).toBeNull() + }) + + it('handles raw wire-format input (decoded UTF-8 string)', () => { + const bytes = buildBytes(userSchema, 'User', { name: 'Carol', age: 7 }) + const text = new TextDecoder().decode(bytes) + const result = autoDetectMessage(text, [ + { schemaId: 'rps_user', schemaName: 'user.proto', content: userSchema }, + ]) + expect(result).not.toBeNull() + expect(result!.messageType).toBe('User') + }) + + it('caches results across consecutive calls', () => { + const bytes = buildBytes(orderSchema, 'Order', { + order_id: 'o_1', + total_cents: '1299', + customer_email: 'a@b.co', + }) + const sources = [ + { schemaId: 'rps_order', schemaName: 'order.proto', content: orderSchema }, + { schemaId: 'rps_user', schemaName: 'user.proto', content: userSchema }, + ] + const a = autoDetectMessage(toBase64(bytes), sources) + const b = autoDetectMessage(toBase64(bytes), sources) + expect(a).toEqual(b) + expect(a!.schemaId).toBe('rps_order') + expect(a!.messageType).toBe('Order') + }) + + it('preserves confidence across cache hits', () => { + // Two schemas that both round-trip the same bytes; without proper caching + // the second call would lose the runner-up score and report "high". + const ambiguousA = ` +syntax = "proto3"; +message MsgA { + string id = 1; + string label = 2; +} +` + const ambiguousB = ` +syntax = "proto3"; +message MsgB { + string id = 1; + string label = 2; +} +` + const bytes = buildBytes(ambiguousA, 'MsgA', { id: 'x', label: 'y' }) + const sources = [ + { schemaId: 'rps_a', schemaName: 'a.proto', content: ambiguousA }, + { schemaId: 'rps_b', schemaName: 'b.proto', content: ambiguousB }, + ] + const first = autoDetectMessage(toBase64(bytes), sources) + const second = autoDetectMessage(toBase64(bytes), sources) + expect(first).not.toBeNull() + expect(second).not.toBeNull() + expect(second!.confidence).toBe(first!.confidence) + expect(second!.score).toBe(first!.score) + }) +}) diff --git a/frontend/src/modules/redis/protobuf.test.ts b/frontend/src/modules/redis/protobuf.test.ts new file mode 100644 index 0000000..3fdd855 --- /dev/null +++ b/frontend/src/modules/redis/protobuf.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest' +import protobuf from 'protobufjs' + +import { decodeRedisProtobufValue, extractProtoMessageTypes } from './protobuf' + +const schema = ` +syntax = "proto3"; + +message UserEvent { + string user_id = 1; + int32 score = 2; +} +` + +describe('redis protobuf helpers', () => { + it('extracts message names from editable proto schema', () => { + expect(extractProtoMessageTypes('')).toEqual([]) + expect(extractProtoMessageTypes(schema)).toEqual(['UserEvent']) + }) + + it('decodes protobuf value from base64 payload', () => { + const root = protobuf.parse(schema, { keepCase: true }).root + const type = root.lookupType('UserEvent') + const bytes = type.encode(type.create({ user_id: 'u_1', score: 42 })).finish() + const base64 = Buffer.from(bytes).toString('base64') + + const result = decodeRedisProtobufValue(base64, schema, 'UserEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(result.lines.join('\n')).toContain('"user_id": "u_1"') + expect(result.lines.join('\n')).toContain('"score": 42') + }) + + it('decodes protobuf value from unpadded base64 payload', () => { + const root = protobuf.parse(schema, { keepCase: true }).root + const type = root.lookupType('UserEvent') + const bytes = type.encode(type.create({ user_id: '434', score: 7 })).finish() + const base64 = Buffer.from(bytes).toString('base64').replace(/=+$/, '') + + const result = decodeRedisProtobufValue(base64, schema, 'UserEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(result.lines.join('\n')).toContain('"user_id": "434"') + expect(result.lines.join('\n')).toContain('"score": 7') + }) + + it('decodes protobuf value from quoted base64 payload copied through Redis CLI style text', () => { + const packagedSchema = ` +syntax = "proto3"; + +package futrix.issue434; + +message UserEvent { + string user_id = 1; + int32 score = 2; + string action = 3; +} +` + const root = protobuf.parse(packagedSchema, { keepCase: true }).root + const type = root.lookupType('futrix.issue434.UserEvent') + const bytes = type.encode(type.create({ user_id: 'issue-434', score: 434, action: 'redis-protobuf' })).finish() + const quotedBase64 = `"${Buffer.from(bytes).toString('base64')}"` + + const result = decodeRedisProtobufValue(quotedBase64, packagedSchema, 'futrix.issue434.UserEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(result.lines.join('\n')).toContain('"user_id": "issue-434"') + expect(result.lines.join('\n')).toContain('"score": 434') + expect(result.lines.join('\n')).toContain('"action": "redis-protobuf"') + }) + + it('decodes protobuf value from raw wire text including leading newline byte', () => { + const root = protobuf.parse(schema, { keepCase: true }).root + const type = root.lookupType('UserEvent') + const bytes = type.encode(type.create({ user_id: 'u_1', score: 7 })).finish() + const wireText = new TextDecoder().decode(bytes) + + const result = decodeRedisProtobufValue(wireText, schema, 'UserEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(result.lines.join('\n')).toContain('"user_id": "u_1"') + expect(result.lines.join('\n')).toContain('"score": 7') + }) + + it('decodes forward-compatible payloads with unknown fields from newer schema', () => { + const newerSchema = ` +syntax = "proto3"; + +message UserEvent { + string user_id = 1; + int32 score = 2; +} +` + const olderSchema = ` +syntax = "proto3"; + +message UserEvent { + string user_id = 1; +} +` + + const root = protobuf.parse(newerSchema, { keepCase: true }).root + const type = root.lookupType('UserEvent') + const bytes = type.encode(type.create({ user_id: 'u_2', score: 99 })).finish() + const base64 = Buffer.from(bytes).toString('base64') + + const result = decodeRedisProtobufValue(base64, olderSchema, 'UserEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(result.lines.join('\n')).toContain('"user_id": "u_2"') + }) + + it('preserves int64 precision by rendering long fields as strings', () => { + const longSchema = ` +syntax = "proto3"; + +message AuditEvent { + int64 event_id = 1; +} +` + const maxInt64 = '9223372036854775807' + const root = protobuf.parse(longSchema, { keepCase: true }).root + const type = root.lookupType('AuditEvent') + const bytes = type.encode(type.create({ event_id: maxInt64 })).finish() + const base64 = Buffer.from(bytes).toString('base64') + + const result = decodeRedisProtobufValue(base64, longSchema, 'AuditEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + const parsed = JSON.parse(result.lines.join('\n')) + expect(parsed.event_id).toBe(maxInt64) + }) + + it('treats empty decoded messages as valid when payload has only unknown fields', () => { + const newerSchema = ` +syntax = "proto3"; + +message CompatEvent { + int32 extra = 5; +} +` + const olderSchema = ` +syntax = "proto3"; + +message CompatEvent {} +` + const root = protobuf.parse(newerSchema, { keepCase: true }).root + const type = root.lookupType('CompatEvent') + const bytes = type.encode(type.create({ extra: 1 })).finish() + const base64 = Buffer.from(bytes).toString('base64') + + const result = decodeRedisProtobufValue(base64, olderSchema, 'CompatEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(JSON.parse(result.lines.join('\n'))).toEqual({}) + }) + + it('treats empty wire payload as valid for empty protobuf message', () => { + const emptySchema = ` +syntax = "proto3"; + +message EmptyEvent {} +` + const result = decodeRedisProtobufValue('', emptySchema, 'EmptyEvent') + expect(result.isProtobuf).toBe(true) + expect(result.message).toBe('') + expect(JSON.parse(result.lines.join('\n'))).toEqual({}) + }) + + it('returns Not a Protobuf value for non-protobuf payload', () => { + const result = decodeRedisProtobufValue('hello-world', schema, 'UserEvent') + expect(result.isProtobuf).toBe(false) + expect(result.message).toBe('Not a Protobuf value.') + expect(result.lines).toEqual(['']) + }) +}) diff --git a/frontend/src/modules/redis/protobuf.ts b/frontend/src/modules/redis/protobuf.ts new file mode 100644 index 0000000..941850c --- /dev/null +++ b/frontend/src/modules/redis/protobuf.ts @@ -0,0 +1,488 @@ +import protobuf from 'protobufjs' + +export const notProtobufValueMessage = 'Not a Protobuf value.' + +type DecodeRedisProtobufResult = { + isProtobuf: boolean + lines: string[] + message: string +} + +const toNotProtobuf = (): DecodeRedisProtobufResult => ({ + isProtobuf: false, + lines: [''], + message: notProtobufValueMessage, +}) + +const toLookupCandidates = (name: string): string[] => { + const trimmed = String(name || '').trim() + if (!trimmed) return [] + if (trimmed.startsWith('.')) return [trimmed, trimmed.slice(1)] + return [trimmed, `.${trimmed}`] +} + +const bytesToHex = (bytes: Uint8Array): string => { + return Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0')).join('') +} + +const decodeBase64 = (text: string): Uint8Array | null => { + try { + if (typeof atob === 'function') { + const decoded = atob(text) + return Uint8Array.from(decoded, (char) => char.charCodeAt(0)) + } + } catch { + return null + } + try { + if (typeof Buffer !== 'undefined') { + return Uint8Array.from(Buffer.from(text, 'base64')) + } + } catch { + return null + } + return null +} + +const decodeHex = (text: string): Uint8Array | null => { + const normalized = text.startsWith('0x') ? text.slice(2) : text + if (normalized.length === 0 || normalized.length % 2 !== 0) return null + if (!/^[0-9a-fA-F]+$/.test(normalized)) return null + const bytes = new Uint8Array(normalized.length / 2) + for (let i = 0; i < normalized.length; i += 2) { + const byte = Number.parseInt(normalized.slice(i, i + 2), 16) + if (!Number.isFinite(byte)) return null + bytes[i / 2] = byte + } + return bytes +} + +const unwrapQuotedText = (text: string): string => { + if (text.length < 2) return text + const first = text[0] + const last = text[text.length - 1] + if ((first !== '"' && first !== "'") || first !== last) return text + try { + if (first === '"') return JSON.parse(text) + } catch { + // Fall through to a conservative Redis-style unquote below. + } + return text.slice(1, -1).replace(/\\(["'\\])/g, '$1') +} + +const normalizeBase64Text = (text: string): string | null => { + const compact = String(text || '').trim().replace(/\s+/g, '') + if (compact.length < 2) return null + const unquoted = unwrapQuotedText(compact) + const normalized = unquoted.replace(/-/g, '+').replace(/_/g, '/') + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) return null + if (/=/.test(normalized.slice(0, -2))) return null + const withoutPadding = normalized.replace(/=+$/, '') + if (withoutPadding.length < 2) return null + const remainder = withoutPadding.length % 4 + if (remainder === 1) return null + return withoutPadding + '='.repeat((4 - remainder) % 4) +} + +const listByteCandidates = (raw: string): Uint8Array[] => { + const text = String(raw ?? '') + const trimmed = text.trim() + const unquotedTrimmed = unwrapQuotedText(trimmed) + const candidates: Uint8Array[] = [] + const seen = new Set() + + const add = (bytes: Uint8Array | null) => { + if (!bytes) return + const normalized = Uint8Array.from(bytes) + const signature = bytesToHex(normalized) + if (seen.has(signature)) return + seen.add(signature) + candidates.push(normalized) + } + + if (/^(?:0x)?[0-9a-fA-F]+$/.test(trimmed)) { + add(decodeHex(trimmed)) + } + if (unquotedTrimmed !== trimmed && /^(?:0x)?[0-9a-fA-F]+$/.test(unquotedTrimmed)) { + add(decodeHex(unquotedTrimmed)) + } + + const base64Text = normalizeBase64Text(trimmed) + if (base64Text) { + add(decodeBase64(base64Text)) + } + + add(new TextEncoder().encode(text)) + if (unquotedTrimmed !== trimmed) { + add(new TextEncoder().encode(unquotedTrimmed)) + } + + return candidates +} + +const parseRoot = (schema: string): protobuf.Root | null => { + const text = String(schema || '').trim() + if (!text) return null + try { + return protobuf.parse(text, { keepCase: true }).root + } catch { + return null + } +} + +// LRU cache of parsed Roots keyed by a cheap content fingerprint. Schemas +// rarely change but get re-parsed often when the user clicks between keys, +// so caching shaves real time off auto-detect. +const ROOT_CACHE_MAX = 32 +const rootCache = new Map() + +const fingerprintSchema = (schema: string): string => { + const text = String(schema || '') + let h = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i) + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0 + } + return `${text.length.toString(36)}_${h.toString(16)}` +} + +const getCachedRoot = (schema: string): protobuf.Root | null => { + const key = fingerprintSchema(schema) + if (rootCache.has(key)) { + const cached = rootCache.get(key) ?? null + rootCache.delete(key) + rootCache.set(key, cached) + return cached + } + const root = parseRoot(schema) + rootCache.set(key, root) + if (rootCache.size > ROOT_CACHE_MAX) { + const oldest = rootCache.keys().next().value + if (oldest) rootCache.delete(oldest) + } + return root +} + +export const clearProtobufRootCache = () => { + rootCache.clear() +} + +export function extractProtoMessageTypes(schema: string): string[] { + const root = getCachedRoot(schema) + if (!root) return [] + + const out: string[] = [] + const walk = (scope: protobuf.NamespaceBase, prefix: string) => { + const nested = Array.isArray(scope.nestedArray) ? scope.nestedArray : [] + for (const item of nested) { + if (item instanceof protobuf.Type) { + out.push(`${prefix}${item.name}`) + } + if (item instanceof protobuf.Namespace) { + walk(item, `${prefix}${item.name}.`) + } + } + } + + walk(root, '') + return out +} + +const normalizeDecoded = (type: protobuf.Type, payload: protobuf.Message<{}>): Record => { + return type.toObject(payload, { + longs: String, + enums: String, + bytes: String, + defaults: false, + arrays: true, + objects: true, + oneofs: true, + }) as Record +} + +const decodeWithType = (type: protobuf.Type, input: Uint8Array): protobuf.Message<{}> | null => { + try { + return type.decode(Uint8Array.from(input)) + } catch { + return null + } +} + +export function decodeRedisProtobufValue(rawValue: string, schema: string, messageName: string): DecodeRedisProtobufResult { + const root = getCachedRoot(schema) + if (!root) return toNotProtobuf() + + let type: protobuf.Type | null = null + for (const candidate of toLookupCandidates(messageName)) { + const lookedUp = root.lookup(candidate) + if (lookedUp instanceof protobuf.Type) { + type = lookedUp + break + } + } + if (!type) return toNotProtobuf() + + const values = listByteCandidates(rawValue) + for (const bytes of values) { + const decoded = decodeWithType(type, bytes) + if (!decoded) continue + const obj = normalizeDecoded(type, decoded) + return { + isProtobuf: true, + lines: JSON.stringify(obj, null, 2).split('\n'), + message: '', + } + } + return toNotProtobuf() +} + +// ---------------- Auto-detect ---------------- + +// Wire-format pre-filter: walk bytes assuming protobuf framing and bail out on +// obvious non-protobuf payloads. Permissive — a true positive only means +// "worth trying decode candidates", not "definitely protobuf". +export function isLikelyProtobuf(bytes: Uint8Array): boolean { + if (!bytes || bytes.length === 0) return false + let i = 0 + let fields = 0 + while (i < bytes.length) { + let tag = 0 + let shift = 0 + let read = 0 + while (i < bytes.length && read < 5) { + const b = bytes[i++] + tag |= (b & 0x7f) << shift + read++ + if ((b & 0x80) === 0) break + shift += 7 + } + if (read === 0) return false + if (read >= 5 && (bytes[i - 1] & 0x80) !== 0) return false + const wireType = tag & 0x7 + const fieldNumber = tag >>> 3 + if (fieldNumber === 0) return false + if (fieldNumber > 536870911) return false + switch (wireType) { + case 0: { + let count = 0 + while (i < bytes.length && count < 10) { + const b = bytes[i++] + count++ + if ((b & 0x80) === 0) break + } + if (count === 0) return false + if (count >= 10 && (bytes[i - 1] & 0x80) !== 0) return false + break + } + case 1: + if (i + 8 > bytes.length) return false + i += 8 + break + case 2: { + let len = 0 + let lshift = 0 + let lread = 0 + while (i < bytes.length && lread < 5) { + const b = bytes[i++] + len |= (b & 0x7f) << lshift + lread++ + if ((b & 0x80) === 0) break + lshift += 7 + } + if (lread === 0) return false + if (len < 0 || i + len > bytes.length) return false + i += len + break + } + case 5: + if (i + 4 > bytes.length) return false + i += 4 + break + default: + return false + } + fields++ + if (fields > 4096) return false + } + return fields > 0 && i === bytes.length +} + +const bytesEqual = (a: Uint8Array, b: Uint8Array): boolean => { + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false + } + return true +} + +const countPopulatedFields = (obj: Record): number => { + let count = 0 + for (const key of Object.keys(obj)) { + const value = (obj as Record)[key] + if (value === null || value === undefined) continue + if (Array.isArray(value)) { + if (value.length === 0) continue + } else if (typeof value === 'string') { + if (value.length === 0) continue + } + count++ + } + return count +} + +export type AutoDetectResult = { + schemaId: string + schemaName: string + messageType: string + confidence: 'high' | 'medium' | 'low' + score: number +} | null + +export type AutoDetectSource = { + schemaId: string + schemaName: string + content: string +} + +export const AUTO_DETECT_MAX_BYTES = 64 * 1024 + +const AUTO_CACHE_MAX = 64 +const autoCache = new Map() + +const fingerprintBytes = (bytes: Uint8Array): string => { + let h = 0x811c9dc5 + for (let i = 0; i < bytes.length; i++) { + h ^= bytes[i] + h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0 + } + return `${bytes.length.toString(36)}_${h.toString(16)}` +} + +const fingerprintSources = (sources: AutoDetectSource[]): string => { + return sources + .map((s) => `${s.schemaId}:${fingerprintSchema(s.content)}`) + .sort() + .join('|') +} + +type RawMatch = { + type: protobuf.Type + source: AutoDetectSource + score: number + roundTrip: boolean +} + +const scoreMatch = (type: protobuf.Type, bytes: Uint8Array): { score: number; roundTrip: boolean } | null => { + const decoded = decodeWithType(type, bytes) + if (!decoded) return null + let obj: Record + try { + obj = normalizeDecoded(type, decoded) + } catch { + return null + } + let reencoded: Uint8Array + try { + reencoded = type.encode(decoded).finish() + } catch { + return { score: 1 + countPopulatedFields(obj) * 10, roundTrip: false } + } + const roundTrip = bytesEqual(reencoded, bytes) + const populated = countPopulatedFields(obj) + const lenRatio = bytes.length === 0 ? 0 : reencoded.length / bytes.length + const proximity = Math.max(0, 1 - Math.abs(1 - lenRatio)) + const declared = type.fieldsArray.length + const coverage = declared === 0 ? 0 : populated / declared + const score = (roundTrip ? 1000 : 0) + populated * 10 + proximity * 50 + coverage * 200 + return { score, roundTrip } +} + +const collectTypes = (root: protobuf.Root): protobuf.Type[] => { + const out: protobuf.Type[] = [] + const walk = (scope: protobuf.NamespaceBase) => { + const nested = Array.isArray(scope.nestedArray) ? scope.nestedArray : [] + for (const item of nested) { + if (item instanceof protobuf.Type) out.push(item) + if (item instanceof protobuf.Namespace) walk(item) + } + } + walk(root) + return out +} + +const detectForBytes = (bytes: Uint8Array, sources: AutoDetectSource[]): AutoDetectResult => { + let bestForBytes: RawMatch | null = null + let secondForBytes = -1 + for (const source of sources) { + const root = getCachedRoot(source.content) + if (!root) continue + for (const type of collectTypes(root)) { + const result = scoreMatch(type, bytes) + if (!result) continue + if (!bestForBytes || result.score > bestForBytes.score) { + if (bestForBytes) secondForBytes = bestForBytes.score + bestForBytes = { type, source, score: result.score, roundTrip: result.roundTrip } + } else if (result.score > secondForBytes) { + secondForBytes = result.score + } + } + } + + if (!bestForBytes || bestForBytes.score <= 0) return null + const margin = bestForBytes.score - secondForBytes + let confidence: 'high' | 'medium' | 'low' | null = null + if (bestForBytes.roundTrip && margin >= 50) confidence = 'high' + else if (bestForBytes.roundTrip) confidence = 'medium' + else if (margin >= 30) confidence = 'low' + if (!confidence) return null + + return { + schemaId: bestForBytes.source.schemaId, + schemaName: bestForBytes.source.schemaName, + messageType: bestForBytes.type.fullName.replace(/^\./, ''), + confidence, + score: bestForBytes.score, + } +} + +export function autoDetectMessage(rawValue: string, sources: AutoDetectSource[]): AutoDetectResult { + if (!Array.isArray(sources) || sources.length === 0) return null + const candidates = listByteCandidates(rawValue) + if (candidates.length === 0) return null + + const usable = candidates.filter((b) => b.length > 0 && b.length <= AUTO_DETECT_MAX_BYTES) + if (usable.length === 0) return null + + const sourcesKey = fingerprintSources(sources) + + let best: AutoDetectResult = null + + for (const bytes of usable) { + if (!isLikelyProtobuf(bytes)) continue + + const cacheKey = `${fingerprintBytes(bytes)}::${sourcesKey}` + let outcome: AutoDetectResult + if (autoCache.has(cacheKey)) { + outcome = autoCache.get(cacheKey) ?? null + autoCache.delete(cacheKey) + autoCache.set(cacheKey, outcome) + } else { + outcome = detectForBytes(bytes, sources) + autoCache.set(cacheKey, outcome) + if (autoCache.size > AUTO_CACHE_MAX) { + const oldest = autoCache.keys().next().value + if (oldest) autoCache.delete(oldest) + } + } + + if (outcome && (!best || outcome.score > best.score)) { + best = outcome + } + } + + return best +} + +export const clearAutoDetectCache = () => { + autoCache.clear() +} diff --git a/frontend/src/modules/redis/tree.test.ts b/frontend/src/modules/redis/tree.test.ts new file mode 100644 index 0000000..2cac080 --- /dev/null +++ b/frontend/src/modules/redis/tree.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { buildTree } from './tree' + +describe('buildTree', () => { + it('builds tree with separator', () => { + const keys = ['a:b:c', 'a:b:d', 'x'] + const tree = buildTree(keys, ':', 3, new Set()) + expect(tree.some((item) => item.id === 'a')).toBe(true) + }) +}) diff --git a/frontend/src/modules/redis/tree.ts b/frontend/src/modules/redis/tree.ts new file mode 100644 index 0000000..293024a --- /dev/null +++ b/frontend/src/modules/redis/tree.ts @@ -0,0 +1,99 @@ +export type RedisTreeItem = { + id: string + label: string + depth: number + isFolder: boolean + isKey: boolean + prefix: string + childrenCount: number +} + +type TreeNode = { + label: string + prefix: string + children: Map + isKey: boolean +} + +const splitKey = (key: string, separator: string, maxDepth: number): string[] => { + if (!separator) return [key] + const parts = key.split(separator).filter((part) => part.length > 0) + if (maxDepth > 0 && parts.length > maxDepth) { + const head = parts.slice(0, maxDepth - 1) + const tail = parts.slice(maxDepth - 1).join(separator) + return [...head, tail] + } + return parts +} + +export const buildTree = ( + keys: string[], + separator: string, + maxDepth: number, + expanded: Set, +): RedisTreeItem[] => { + const root: TreeNode = { + label: '', + prefix: '', + children: new Map(), + isKey: false, + } + + for (const raw of keys) { + if (raw === null || raw === undefined) continue + const key = String(raw) + const parts = splitKey(key, separator, maxDepth) + if (!parts.length) continue + let node = root + const path: string[] = [] + parts.forEach((part, idx) => { + path.push(part) + const prefix = separator ? path.join(separator) : part + let child = node.children.get(part) + if (!child) { + child = { + label: part, + prefix, + children: new Map(), + isKey: false, + } + node.children.set(part, child) + } + if (idx === parts.length - 1) { + child.isKey = true + } + node = child + }) + } + + const items: RedisTreeItem[] = [] + + const walk = (node: TreeNode, depth: number) => { + const children = Array.from(node.children.values()) + children.sort((a, b) => { + const aFolder = a.children.size > 0 + const bFolder = b.children.size > 0 + if (aFolder !== bFolder) return aFolder ? -1 : 1 + return a.label.localeCompare(b.label) + }) + for (const child of children) { + const childrenCount = child.children.size + const isFolder = childrenCount > 0 + items.push({ + id: child.prefix, + label: child.label, + depth, + isFolder, + isKey: child.isKey, + prefix: child.prefix, + childrenCount, + }) + if (isFolder && expanded.has(child.prefix)) { + walk(child, depth + 1) + } + } + } + + walk(root, 0) + return items +} diff --git a/frontend/src/modules/redis/type-theme.test.ts b/frontend/src/modules/redis/type-theme.test.ts new file mode 100644 index 0000000..3283fae --- /dev/null +++ b/frontend/src/modules/redis/type-theme.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import { + normalizeRedisType, + redisTypeAccent, + redisTypePillClass, + redisTypeShort, +} from './type-theme' + +describe('normalizeRedisType', () => { + it('maps canonical lowercase types', () => { + expect(normalizeRedisType('string')).toBe('STRING') + expect(normalizeRedisType('hash')).toBe('HASH') + expect(normalizeRedisType('list')).toBe('LIST') + expect(normalizeRedisType('set')).toBe('SET') + expect(normalizeRedisType('zset')).toBe('ZSET') + expect(normalizeRedisType('stream')).toBe('STREAM') + }) + + it('accepts uppercase, mixed case, and short alias', () => { + expect(normalizeRedisType('HASH')).toBe('HASH') + expect(normalizeRedisType('Hash')).toBe('HASH') + expect(normalizeRedisType('STR')).toBe('STRING') + expect(normalizeRedisType(' zset ')).toBe('ZSET') + }) + + it('returns UNKNOWN for empty or unrecognized', () => { + expect(normalizeRedisType('')).toBe('UNKNOWN') + expect(normalizeRedisType(null)).toBe('UNKNOWN') + expect(normalizeRedisType(undefined)).toBe('UNKNOWN') + expect(normalizeRedisType('bogus')).toBe('UNKNOWN') + expect(normalizeRedisType('none')).toBe('UNKNOWN') + }) +}) + +describe('redisTypeShort', () => { + it('returns short labels', () => { + expect(redisTypeShort('string')).toBe('STR') + expect(redisTypeShort('hash')).toBe('HASH') + expect(redisTypeShort('list')).toBe('LIST') + expect(redisTypeShort('set')).toBe('SET') + expect(redisTypeShort('zset')).toBe('ZSET') + expect(redisTypeShort('stream')).toBe('STREAM') + }) + + it('returns empty string for unknown', () => { + expect(redisTypeShort('')).toBe('') + expect(redisTypeShort('bogus')).toBe('') + }) +}) + +describe('redisTypeAccent', () => { + it('returns distinct pill class per known type', () => { + const types = ['string', 'hash', 'list', 'set', 'zset', 'stream'] as const + const pills = new Set(types.map((t) => redisTypeAccent(t).pill)) + expect(pills.size).toBe(types.length) + }) + + it('always includes base pill class', () => { + const accent = redisTypeAccent('hash') + expect(accent.pill).toContain('rounded') + expect(accent.pill).toContain('font-bold') + expect(accent.pill).toContain('border') + }) + + it('exposes cssVar for non-Tailwind contexts', () => { + expect(redisTypeAccent('string').cssVar).toContain('--redis-accent-string') + expect(redisTypeAccent('hash').cssVar).toContain('--redis-accent-hash') + expect(redisTypeAccent('bogus').cssVar).toContain('--redis-accent-unknown') + }) + + it('uses neutral accent for unknown', () => { + expect(redisTypeAccent('').pill).toContain('slate') + expect(redisTypeAccent('bogus').pill).toContain('slate') + }) +}) + +describe('redisTypePillClass', () => { + it('mirrors redisTypeAccent().pill', () => { + expect(redisTypePillClass('hash')).toBe(redisTypeAccent('hash').pill) + expect(redisTypePillClass('')).toBe(redisTypeAccent('').pill) + }) +}) diff --git a/frontend/src/modules/redis/type-theme.ts b/frontend/src/modules/redis/type-theme.ts new file mode 100644 index 0000000..1f2baa3 --- /dev/null +++ b/frontend/src/modules/redis/type-theme.ts @@ -0,0 +1,117 @@ +// Centralized Redis type theming so tree pills, inspector header badges, +// preview table headers, and selected-row borders stay in sync. +// +// All consumers should call normalizeRedisType() first. + +export type RedisType = 'STRING' | 'HASH' | 'LIST' | 'SET' | 'ZSET' | 'STREAM' | 'UNKNOWN' + +export type RedisShortType = 'STR' | 'HASH' | 'LIST' | 'SET' | 'ZSET' | 'STREAM' | '' + +export interface RedisTypeAccent { + pill: string + headerBg: string + headerText: string + ring: string + border: string + cssVar: string +} + +const PILL_BASE = 'px-1 py-0.5 text-[10px] rounded font-bold border' + +const NORMAL_LOOKUP: Record = { + string: 'STRING', + str: 'STRING', + hash: 'HASH', + list: 'LIST', + set: 'SET', + zset: 'ZSET', + stream: 'STREAM', +} + +const SHORT_LOOKUP: Record = { + STRING: 'STR', + HASH: 'HASH', + LIST: 'LIST', + SET: 'SET', + ZSET: 'ZSET', + STREAM: 'STREAM', + UNKNOWN: '', +} + +export function normalizeRedisType(raw: unknown): RedisType { + const v = String(raw ?? '').trim().toLowerCase() + if (!v) return 'UNKNOWN' + if (v in NORMAL_LOOKUP) return NORMAL_LOOKUP[v] + return 'UNKNOWN' +} + +export function redisTypeShort(raw: unknown): RedisShortType { + return SHORT_LOOKUP[normalizeRedisType(raw)] +} + +const ACCENT_MAP: Record = { + STRING: { + pill: `${PILL_BASE} bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30`, + headerBg: 'bg-blue-50 dark:bg-blue-500/10', + headerText: 'text-blue-700 dark:text-blue-300', + ring: 'ring-blue-200 dark:ring-blue-500/30', + border: 'border-blue-400 dark:border-blue-500/60', + cssVar: 'var(--redis-accent-string, #2563eb)', + }, + HASH: { + pill: `${PILL_BASE} bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-400 dark:border-purple-500/30`, + headerBg: 'bg-purple-50 dark:bg-purple-500/10', + headerText: 'text-purple-700 dark:text-purple-300', + ring: 'ring-purple-200 dark:ring-purple-500/30', + border: 'border-purple-400 dark:border-purple-500/60', + cssVar: 'var(--redis-accent-hash, #7c3aed)', + }, + LIST: { + pill: `${PILL_BASE} bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30`, + headerBg: 'bg-amber-50 dark:bg-amber-500/10', + headerText: 'text-amber-800 dark:text-amber-300', + ring: 'ring-amber-200 dark:ring-amber-500/30', + border: 'border-amber-400 dark:border-amber-500/60', + cssVar: 'var(--redis-accent-list, #d97706)', + }, + SET: { + pill: `${PILL_BASE} bg-green-100 text-green-700 border-green-200 dark:bg-green-500/20 dark:text-green-400 dark:border-green-500/30`, + headerBg: 'bg-green-50 dark:bg-green-500/10', + headerText: 'text-green-700 dark:text-green-300', + ring: 'ring-green-200 dark:ring-green-500/30', + border: 'border-green-400 dark:border-green-500/60', + cssVar: 'var(--redis-accent-set, #16a34a)', + }, + ZSET: { + pill: `${PILL_BASE} bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-400 dark:border-orange-500/30`, + headerBg: 'bg-orange-50 dark:bg-orange-500/10', + headerText: 'text-orange-700 dark:text-orange-300', + ring: 'ring-orange-200 dark:ring-orange-500/30', + border: 'border-orange-400 dark:border-orange-500/60', + cssVar: 'var(--redis-accent-zset, #ea580c)', + }, + STREAM: { + pill: `${PILL_BASE} bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-500/20 dark:text-cyan-400 dark:border-cyan-500/30`, + headerBg: 'bg-cyan-50 dark:bg-cyan-500/10', + headerText: 'text-cyan-700 dark:text-cyan-300', + ring: 'ring-cyan-200 dark:ring-cyan-500/30', + border: 'border-cyan-400 dark:border-cyan-500/60', + cssVar: 'var(--redis-accent-stream, #0891b2)', + }, + UNKNOWN: { + pill: `${PILL_BASE} bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30`, + headerBg: 'bg-slate-50 dark:bg-slate-500/10', + headerText: 'text-slate-700 dark:text-slate-300', + ring: 'ring-slate-200 dark:ring-slate-500/30', + border: 'border-slate-300 dark:border-slate-500/40', + cssVar: 'var(--redis-accent-unknown, #64748b)', + }, +} + +export function redisTypeAccent(raw: unknown): RedisTypeAccent { + return ACCENT_MAP[normalizeRedisType(raw)] +} + +export function redisTypePillClass(raw: unknown): string { + return redisTypeAccent(raw).pill +} diff --git a/frontend/src/modules/riskRules/builtinCatalog.ts b/frontend/src/modules/riskRules/builtinCatalog.ts new file mode 100644 index 0000000..5a44936 --- /dev/null +++ b/frontend/src/modules/riskRules/builtinCatalog.ts @@ -0,0 +1,55 @@ +import { tApp, tAppEn } from '@/modules/i18n/appI18n' + +type RiskRuleLike = { + id?: string + code?: string + builtin?: boolean + description?: string +} + +export const isProbeBuiltinRule = (rule: RiskRuleLike | null | undefined) => ( + Boolean(rule?.builtin && String(rule?.id || '').startsWith('probe-')) +) + +const builtinKey = (code: string, suffix: 'title' | 'summary' | 'trigger') => `riskRules.builtin.${code}.${suffix}` + +const hasBuiltinKey = (code: string, suffix: 'title' | 'summary' | 'trigger') => ( + tAppEn(builtinKey(code, suffix)) !== builtinKey(code, suffix) +) + +export const builtinRuleTitle = (rule: RiskRuleLike) => { + const code = String(rule.code || '').trim() + if (code && hasBuiltinKey(code, 'title')) return tApp(builtinKey(code, 'title')) + return String(rule.description || rule.id || '') +} + +export const builtinRuleSummary = (rule: RiskRuleLike) => { + const code = String(rule.code || '').trim() + if (code && hasBuiltinKey(code, 'summary')) return tApp(builtinKey(code, 'summary')) + return String(rule.description || rule.id || '') +} + +export const builtinRuleTrigger = (rule: RiskRuleLike) => { + const code = String(rule.code || '').trim() + if (code && hasBuiltinKey(code, 'trigger')) return tApp(builtinKey(code, 'trigger')) + return '' +} + +export const editableProbeThresholdFields = (ruleId: string): string[] => { + switch (String(ruleId || '')) { + case 'probe-no-index': + return ['allowSafeSeqScan', 'seqScanRowsThreshold', 'costThreshold'] + case 'probe-wide-scan': + return ['maxExaminedRows'] + case 'probe-plan-risk': + return ['maxJoinCount', 'maxFullScans', 'maxEstimatedJoinRows'] + case 'probe-access-path': + return ['maxDynamoDBPages', 'maxDynamoDBEvaluatedItems'] + default: + return [] + } +} + +export const canEditProbeRule = (rule: RiskRuleLike | null | undefined) => ( + isProbeBuiltinRule(rule) && editableProbeThresholdFields(String(rule?.id || '')).length > 0 +) diff --git a/frontend/src/modules/sql/error-parser.test.ts b/frontend/src/modules/sql/error-parser.test.ts new file mode 100644 index 0000000..1ae3844 --- /dev/null +++ b/frontend/src/modules/sql/error-parser.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { parseSqlExecutionError } from './error-parser' + +describe('parseSqlExecutionError', () => { + it('returns unknown for empty input', () => { + const r = parseSqlExecutionError('') + expect(r.kind).toBe('unknown') + expect(r.position).toBeUndefined() + }) + + it('parses MySQL 1064 with near and line', () => { + const raw = `Error 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''100000 ORDER BY id ASC LIMIT 201' at line 1` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('mysql_syntax') + expect(r.friendlyKey).toBe('console.error.mysql.syntaxNear') + // Without SQL, fall back to column 1 — line is the only known anchor. + expect(r.position).toEqual({ line: 1, column: 1 }) + expect(r.snippet?.startsWith('100000')).toBe(true) + }) + + it('derives MySQL syntax column from the SQL when provided', () => { + const sql = "SELECT * FROM users WHERE id = '100000" + const raw = `Error 1064 (42000): You have an error in your SQL syntax near ''100000' at line 1` + const r = parseSqlExecutionError(raw, sql) + expect(r.kind).toBe('mysql_syntax') + // The snippet `100000` starts after `WHERE id = '` at column 33. + expect(r.position?.line).toBe(1) + expect(r.position?.column).toBe(sql.indexOf('100000') + 1) + }) + + it('falls back to column 1 when the snippet is not found in the SQL', () => { + const sql = 'SELECT 1' + const raw = `Error 1064: syntax error near 'nonexistent' at line 1` + const r = parseSqlExecutionError(raw, sql) + expect(r.position?.column).toBe(1) + }) + + it('handles MySQL 1064 with backend-added pagination suffix', () => { + // Real-world case: user runs `WHERE id = '100000` (no terminator). The + // executor appends ` ORDER BY id ASC LIMIT 201` for paging, then MySQL + // errors with the FULL augmented snippet. Our search must still find the + // user's original token. + const sql = "SELECT * FROM t WHERE id = '100000" + const raw = `Error 1064 (42000): syntax to use near ''100000 ORDER BY id ASC LIMIT 201' at line 1` + const r = parseSqlExecutionError(raw, sql) + expect(r.kind).toBe('mysql_syntax') + expect(r.position?.line).toBe(1) + expect(r.position?.column).toBe(sql.indexOf('100000') + 1) + }) + + it('correctly anchors MySQL syntax to a multiline statement', () => { + const sql = "SELECT *\nFROM users\nWHERE id = '100000" + const raw = `Error 1064: syntax error near ''100000' at line 3` + const r = parseSqlExecutionError(raw, sql) + expect(r.position?.line).toBe(3) + // Column where `100000` starts on line 3 (`WHERE id = '100000`). + expect(r.position?.column).toBe('WHERE id = \''.length + 1) + }) + + it('finds the snippet on the correct editor line when MySQL hint line is off', () => { + // The editor has leading blank lines (e.g. user pressed Enter at the top). + // MySQL still says "at line 1" since it counts from the start of the + // executed SQL after trim; the parser should locate the snippet by + // content, not blindly trust the hint. + const sql = "\n\nSELECT * FROM users WHERE id = '100000" + const raw = `Error 1064: syntax error near ''100000' at line 1` + const r = parseSqlExecutionError(raw, sql) + expect(r.position?.line).toBe(3) + expect(r.position?.column).toBe(sql.split('\n')[2].indexOf('100000') + 1) + }) + + it('parses MySQL syntax error with near but no line', () => { + const raw = `You have an error in your SQL syntax near 'WHERX id'` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('mysql_syntax') + expect(r.position).toBeUndefined() + expect(r.snippet).toBe('WHERX id') + }) + + it('parses MySQL unknown column', () => { + const raw = `Error 1054 (42S22): Unknown column 'naem' in 'where clause'` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('mysql_unknown_column') + expect(r.friendlyParams?.column).toBe('naem') + expect(r.friendlyParams?.where).toBe('where clause') + }) + + it('parses MySQL unknown table', () => { + const raw = `Error 1146 (42S02): Table 'db.usrs' doesn't exist` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('mysql_unknown_table') + expect(r.friendlyParams?.table).toBe('db.usrs') + }) + + it('parses Postgres syntax error with LINE block and caret column', () => { + // The caret is below the F of FRM. "LINE 1: " prefix = 8 chars, F of FRM + // is at SQL column 10 (after "SELECT * "), so caret line has 17 leading + // chars before ^. + const raw = `pq: syntax error at or near "FRM"\nLINE 1: SELECT * FRM users WHERE id = 1\n ^` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('postgres_syntax') + expect(r.position?.line).toBe(1) + expect(r.position?.column).toBe(10) + expect(r.snippet?.toLowerCase()).toContain('frm') + }) + + it('uses caret position relative to the LINE prefix, not the absolute position field', () => { + // PG also includes an absolute `position: N` field (offset into the + // original SQL). The caret column is what we want; the absolute offset + // would jump to the wrong character on a multiline statement. + // "LINE 2: " prefix = 8 chars, caret is below the F of FRM at column 1. + const raw = `ERROR: syntax error at or near "FRM"\nLINE 2: FRM users\n ^\nposition: 12` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('postgres_syntax') + expect(r.position?.line).toBe(2) + expect(r.position?.column).toBe(1) + }) + + it('anchors PG syntax error to the editor when SQL is provided (substatement case)', () => { + // User runs only the second statement of a multi-statement editor. The + // driver reports LINE 1 (relative to the executed substatement), but the + // editor needs to focus the actual line where the snippet lives. + const editor = 'SELECT 1;\nSELECT * FRM users WHERE id = 1' + const raw = `ERROR: syntax error at or near "FRM"\nLINE 1: SELECT * FRM users WHERE id = 1\n ^` + const r = parseSqlExecutionError(raw, editor) + expect(r.kind).toBe('postgres_syntax') + expect(r.position?.line).toBe(2) + expect(r.position?.column).toBe(editor.split('\n')[1].indexOf('FRM') + 1) + }) + + it('parses Postgres undefined column', () => { + const raw = `ERROR: column "naem" does not exist (SQLSTATE 42703)` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('postgres_undefined_column') + expect(r.friendlyParams?.column).toBe('naem') + }) + + it('attaches position to Postgres undefined column when LINE block is present', () => { + const sql = 'SELECT naem FROM users' + const raw = `ERROR: column "naem" does not exist\nLINE 1: SELECT naem FROM users\n ^` + const r = parseSqlExecutionError(raw, sql) + expect(r.kind).toBe('postgres_undefined_column') + expect(r.position?.line).toBe(1) + expect(r.position?.column).toBe(sql.indexOf('naem') + 1) + }) + + it('respects PG hint line so duplicate tokens earlier in the editor are not picked', () => { + // The token `FRM` happens to also appear in the comment of line 1; the PG + // hint says line 3, so we should anchor there. + const editor = '-- FRM old typo\nSELECT 1;\nSELECT * FRM users' + const raw = `ERROR: syntax error at or near "FRM"\nLINE 3: SELECT * FRM users\n ^` + const r = parseSqlExecutionError(raw, editor) + expect(r.position?.line).toBe(3) + expect(r.position?.column).toBe('SELECT * '.length + 1) + }) + + it('parses Postgres undefined table', () => { + const raw = `ERROR: relation "usrs" does not exist` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('postgres_undefined_table') + expect(r.friendlyParams?.table).toBe('usrs') + }) + + it('falls back to generic syntax for "syntax error" without position', () => { + const raw = `some driver: syntax error somewhere` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('generic_syntax') + }) + + it('extracts the near-token for PG syntax errors without a LINE block', () => { + // PG drivers can return just `pq: syntax error at or near "FROM"` with no + // LINE/position metadata. The friendly key requires {snippet}, so we must + // surface the near-token to avoid showing a literal {snippet} placeholder. + const raw = `pq: syntax error at or near "FROM"` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('postgres_syntax') + expect(r.friendlyParams?.snippet).toBe('FROM') + expect(r.snippet).toBe('FROM') + }) + + it('falls back to generic_syntax for PG syntax error with no LINE and no near-token', () => { + // No position, no snippet — render the generic friendly text rather than + // the snippet-templated PG message that would otherwise produce a literal + // `{snippet}` placeholder. + const raw = `pq: syntax error at end of input` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('generic_syntax') + expect(r.friendlyParams).toBeUndefined() + }) + + it('returns unknown for non-syntax errors', () => { + const raw = `connection refused` + const r = parseSqlExecutionError(raw) + expect(r.kind).toBe('unknown') + expect(r.rawMessage).toBe(raw) + }) +}) diff --git a/frontend/src/modules/sql/error-parser.ts b/frontend/src/modules/sql/error-parser.ts new file mode 100644 index 0000000..65e8c9d --- /dev/null +++ b/frontend/src/modules/sql/error-parser.ts @@ -0,0 +1,278 @@ +export type SqlErrorKind = + | 'mysql_syntax' + | 'mysql_unknown_column' + | 'mysql_unknown_table' + | 'postgres_syntax' + | 'postgres_undefined_column' + | 'postgres_undefined_table' + | 'generic_syntax' + | 'unknown' + +export type SqlErrorPosition = { + line: number + column: number +} + +export type ParsedSqlError = { + kind: SqlErrorKind + friendlyKey: string + friendlyParams?: Record + rawMessage: string + position?: SqlErrorPosition + snippet?: string +} + +const MYSQL_NEAR_LINE = /near\s+['"`](.*?)['"`]\s*at\s+line\s+(\d+)/i +const MYSQL_NEAR_ONLY = /near\s+['"`](.*?)['"`]/i +const MYSQL_UNKNOWN_COLUMN = /Unknown column ['"`]([^'"`]+)['"`]\s+in\s+['"`]([^'"`]+)['"`]/i +const MYSQL_UNKNOWN_TABLE = /Table ['"`]([^'"`]+)['"`] doesn'?t exist/i + +// Capture the snippet line AND the optional caret line below it. PG prints: +// LINE 2: FRM users +// ^ +// We use the caret column (within the snippet) for the editor column, NOT the +// `position:` field, which is an absolute offset into the original SQL and is +// not interpretable as a per-line column without the original statement. +const POSTGRES_LINE_BLOCK = /LINE\s+(\d+):(.*?)(?:\n(\s*\^)|$|\n)/i +const POSTGRES_UNDEFINED_COLUMN = /column ["']?([^"'\s]+)["']?\s+does not exist/i +const POSTGRES_UNDEFINED_TABLE = /relation ["']?([^"'\s]+)["']?\s+does not exist/i + +const inferSnippetColumn = (snippet: string): number | undefined => { + if (!snippet) return undefined + const match = snippet.search(/\S/) + return match >= 0 ? match + 1 : undefined +} + +const stripQuoteChars = (s: string): string => s.replace(/^[`'"]+|[`'"]+$/g, '') + +// Locate the snippet within the user's SQL. Returns the 1-indexed (line, column) +// where the snippet starts, or null if not found. The first 24 chars of the +// snippet are used as the probe (MySQL truncates to a similar length). +// +// `hintLine` is the line reported by the driver (e.g. MySQL "at line N"); when +// the snippet occurs multiple times, we prefer the match on that line. When the +// hint doesn't match anything, we fall back to the first occurrence. +// Try progressively shorter probes so we still find a useful anchor when the +// driver-reported snippet contains tokens not in the user's SQL (e.g. MySQL +// 1064 echoes back the backend-added "ORDER BY id ASC LIMIT 201" pagination +// suffix that's never in the editor). +const buildProbes = (snippet: string): string[] => { + const probes: string[] = [] + const trimmed = snippet.trim() + if (!trimmed) return probes + const seen = new Set() + const add = (p: string) => { + const v = p.trim() + if (v && !seen.has(v)) { + seen.add(v) + probes.push(v) + } + } + add(trimmed.slice(0, 24)) + // First whitespace-delimited token — typically the actual offending identifier. + const firstToken = trimmed.split(/\s+/)[0] || '' + add(firstToken) + // Shorter prefixes as last resort. + add(trimmed.slice(0, 8)) + add(trimmed.slice(0, 4)) + return probes +} + +const findPositionInSql = ( + sql: string, + snippet: string, + hintLine?: number, +): { line: number; column: number } | null => { + if (!sql || !snippet) return null + const probes = buildProbes(snippet) + if (!probes.length) return null + const lines = sql.replace(/\r\n/g, '\n').split('\n') + for (const probe of probes) { + if (hintLine && hintLine >= 1 && hintLine <= lines.length) { + const target = lines[hintLine - 1] + const idx = target.indexOf(probe) + if (idx >= 0) return { line: hintLine, column: idx + 1 } + } + for (let i = 0; i < lines.length; i += 1) { + const idx = lines[i].indexOf(probe) + if (idx >= 0) return { line: i + 1, column: idx + 1 } + } + } + return null +} + +export function parseSqlExecutionError(raw: string, sql = ''): ParsedSqlError { + const message = String(raw || '').trim() + if (!message) { + return { + kind: 'unknown', + friendlyKey: 'console.error.unknown', + rawMessage: '', + } + } + + // Postgres signals first — its messages can also contain `near '...'` which + // would otherwise be misclassified as MySQL 1064. + const hasPostgresSignal = + /\bLINE\s+\d+:/i.test(message) || + /\bposition:\s*\d+/i.test(message) || + /does\s+not\s+exist/i.test(message) || + /^pq:/i.test(message) || + /SQLSTATE\s+\d{5}/i.test(message) + + if (hasPostgresSignal) { + // Parse position info from the LINE block once so the friendly result for + // undefined-column / undefined-table can carry a position too. + const pgLine = message.match(POSTGRES_LINE_BLOCK) + const pgHintLine = pgLine ? Number.parseInt(pgLine[1], 10) || 1 : undefined + const pgSnippetWithLeading = pgLine ? pgLine[2] || '' : '' + const pgCaretCapture = pgLine ? pgLine[3] || '' : '' + const pgPrefixLength = pgLine + ? 'LINE '.length + pgLine[1].length + ': '.length + : 0 + const nearTokenMatch = message.match(/at\s+or\s+near\s+["'`]([^"'`]+)["'`]/i) + const nearToken = nearTokenMatch ? stripQuoteChars(nearTokenMatch[1]) : '' + + const resolvePgPosition = (token: string): SqlErrorPosition | undefined => { + // Prefer the editor location of the offending token, constrained by the + // PG hint line so duplicate tokens in earlier statements are not picked. + if (sql && token) { + const found = findPositionInSql(sql, token, pgHintLine) + if (found) return found + } + if (!pgLine || !pgHintLine) return undefined + let column: number + if (pgCaretCapture) { + column = Math.max(1, pgCaretCapture.length - pgPrefixLength) + } else { + column = inferSnippetColumn(pgSnippetWithLeading) ?? 1 + } + return { line: pgHintLine, column } + } + + const pgUndefinedCol = message.match(POSTGRES_UNDEFINED_COLUMN) + if (pgUndefinedCol) { + const position = resolvePgPosition(pgUndefinedCol[1]) + return { + kind: 'postgres_undefined_column', + friendlyKey: 'console.error.postgres.undefinedColumn', + friendlyParams: { column: pgUndefinedCol[1] }, + rawMessage: message, + ...(position ? { position } : {}), + } + } + + const pgUndefinedTable = message.match(POSTGRES_UNDEFINED_TABLE) + if (pgUndefinedTable) { + const position = resolvePgPosition(pgUndefinedTable[1]) + return { + kind: 'postgres_undefined_table', + friendlyKey: 'console.error.postgres.undefinedTable', + friendlyParams: { table: pgUndefinedTable[1] }, + rawMessage: message, + ...(position ? { position } : {}), + } + } + + if (pgLine) { + const trimmedSnippet = pgSnippetWithLeading.trim() + const position = resolvePgPosition(nearToken) ?? { + line: pgHintLine ?? 1, + column: pgCaretCapture + ? Math.max(1, pgCaretCapture.length - pgPrefixLength) + : inferSnippetColumn(pgSnippetWithLeading) ?? 1, + } + return { + kind: 'postgres_syntax', + friendlyKey: 'console.error.postgres.syntax', + friendlyParams: { snippet: (nearToken || trimmedSnippet).slice(0, 40) }, + rawMessage: message, + position, + snippet: nearToken || trimmedSnippet, + } + } + + if (/syntax\s+error/i.test(message)) { + // Try to recover an "at or near" token so the friendly message renders + // a useful snippet instead of a literal {snippet} placeholder. + const nearMatch = message.match(/at\s+or\s+near\s+["'`]([^"'`]+)["'`]/i) + if (nearMatch) { + const snippet = stripQuoteChars(nearMatch[1]) + return { + kind: 'postgres_syntax', + friendlyKey: 'console.error.postgres.syntax', + friendlyParams: { snippet: snippet.slice(0, 40) }, + rawMessage: message, + snippet, + } + } + return { + kind: 'generic_syntax', + friendlyKey: 'console.error.genericSyntax', + rawMessage: message, + } + } + } + + const mysqlUnknownCol = message.match(MYSQL_UNKNOWN_COLUMN) + if (mysqlUnknownCol) { + return { + kind: 'mysql_unknown_column', + friendlyKey: 'console.error.mysql.unknownColumn', + friendlyParams: { column: mysqlUnknownCol[1], where: mysqlUnknownCol[2] }, + rawMessage: message, + } + } + + const mysqlUnknownTable = message.match(MYSQL_UNKNOWN_TABLE) + if (mysqlUnknownTable) { + return { + kind: 'mysql_unknown_table', + friendlyKey: 'console.error.mysql.unknownTable', + friendlyParams: { table: mysqlUnknownTable[1] }, + rawMessage: message, + } + } + + const mysqlNearLine = message.match(MYSQL_NEAR_LINE) + if (mysqlNearLine) { + const snippet = stripQuoteChars(mysqlNearLine[1]) + const hintLine = Number.parseInt(mysqlNearLine[2], 10) || 1 + const found = findPositionInSql(sql, snippet, hintLine) + const position = found ?? { line: hintLine, column: 1 } + return { + kind: 'mysql_syntax', + friendlyKey: 'console.error.mysql.syntaxNear', + friendlyParams: { snippet: snippet.slice(0, 40) }, + rawMessage: message, + position, + snippet, + } + } + + const mysqlNearOnly = message.match(MYSQL_NEAR_ONLY) + if (mysqlNearOnly) { + const snippet = stripQuoteChars(mysqlNearOnly[1]) + return { + kind: 'mysql_syntax', + friendlyKey: 'console.error.mysql.syntaxNear', + friendlyParams: { snippet: snippet.slice(0, 40) }, + rawMessage: message, + snippet, + } + } + + if (/syntax\s+error/i.test(message)) { + return { + kind: 'generic_syntax', + friendlyKey: 'console.error.genericSyntax', + rawMessage: message, + } + } + + return { + kind: 'unknown', + friendlyKey: 'console.error.unknown', + rawMessage: message, + } +} diff --git a/frontend/src/modules/sql/hints.test.ts b/frontend/src/modules/sql/hints.test.ts new file mode 100644 index 0000000..4f7bb22 --- /dev/null +++ b/frontend/src/modules/sql/hints.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { getMySQLHint } from './mysql' +import { getPostgresHint, quotePostgresIdentifierIfNeeded } from './postgres' + +describe('SQL datasource hints', () => { + it('does not imply fixed required port', () => { + expect(getMySQLHint()).toContain('port (default 3306)') + expect(getPostgresHint()).toContain('port (default 5432)') + }) +}) + +describe('quotePostgresIdentifierIfNeeded', () => { + it('keeps simple lowercase identifiers unquoted', () => { + expect(quotePostgresIdentifierIfNeeded('id')).toBe('id') + expect(quotePostgresIdentifierIfNeeded('public.orders')).toBe('public.orders') + }) + + it('quotes case-sensitive and reserved identifiers', () => { + expect(quotePostgresIdentifierIfNeeded('UserID')).toBe('"UserID"') + expect(quotePostgresIdentifierIfNeeded('order')).toBe('"order"') + }) + + it('preserves quoted identifiers that include dots', () => { + expect(quotePostgresIdentifierIfNeeded('"tenant.id"')).toBe('"tenant.id"') + expect(quotePostgresIdentifierIfNeeded('public."tenant.id"')).toBe('public."tenant.id"') + expect(quotePostgresIdentifierIfNeeded('tenant.id', { treatDotAsPath: false })).toBe('"tenant.id"') + }) +}) diff --git a/frontend/src/modules/sql/mysql.ts b/frontend/src/modules/sql/mysql.ts new file mode 100644 index 0000000..6c97e58 --- /dev/null +++ b/frontend/src/modules/sql/mysql.ts @@ -0,0 +1,298 @@ +export function getMySQLHint() { + return 'Required: name, host, port (default 3306), username. Optional: database (defaults to mysql).' +} + +const mySqlKeywordsNeedingQuotes = new Set( + ` +accessible +add +all +alter +analyze +and +as +asc +asensitive +before +between +bigint +binary +blob +both +by +call +cascade +case +change +char +character +check +collate +column +condition +constraint +continue +convert +create +cross +cube +cume_dist +current_date +current_time +current_timestamp +current_user +cursor +database +databases +day_hour +day_microsecond +day_minute +day_second +dec +decimal +declare +default +delayed +delete +dense_rank +desc +describe +deterministic +distinct +distinctrow +div +double +drop +dual +each +else +elseif +empty +enclosed +escaped +except +exists +exit +explain +false +fetch +first_value +float +float4 +float8 +for +force +foreign +from +fulltext +function +generated +get +grant +group +grouping +groups +having +high_priority +hour_microsecond +hour_minute +hour_second +if +ignore +in +index +infile +inner +inout +insensitive +insert +int +int1 +int2 +int3 +int4 +int8 +integer +intersect +interval +into +io_after_gtids +io_before_gtids +is +iterate +join +json_table +key +keys +kill +lag +last_value +lateral +lead +leading +leave +left +like +limit +linear +lines +load +localtime +localtimestamp +lock +long +longblob +longtext +loop +low_priority +master_bind +master_ssl_verify_server_cert +match +maxvalue +mediumblob +mediumint +mediumtext +middleint +minute_microsecond +minute_second +mod +modifies +natural +not +no_write_to_binlog +nth_value +ntile +null +numeric +of +on +optimize +optimizer_costs +option +optionally +or +order +out +outer +outfile +over +partition +percent_rank +precision +primary +procedure +purge +range +rank +read +reads +read_write +real +recursive +references +regexp +release +rename +repeat +replace +require +resignal +restrict +return +revoke +right +rlike +row +rows +row_number +schema +schemas +second_microsecond +select +sensitive +separator +set +show +signal +smallint +spatial +specific +sql +sqlexception +sqlstate +sqlwarning +sql_big_result +sql_calc_found_rows +sql_small_result +ssl +starting +stored +straight_join +system +table +terminated +then +tinyblob +tinyint +tinytext +to +trailing +trigger +true +undo +union +unique +unlock +unsigned +update +usage +use +using +utc_date +utc_time +utc_timestamp +values +varbinary +varchar +varcharacter +varying +virtual +when +where +while +window +with +write +xor +year_month +zerofill +value +` + .trim() + .split(/\s+/), +) + +const escapeMySqlIdentifier = (value: string) => value.replaceAll('`', '``') + +const isSimpleIdentifier = (value: string) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) + +const shouldQuoteMySqlIdentifier = (value: string) => { + if (!isSimpleIdentifier(value)) return true + return mySqlKeywordsNeedingQuotes.has(value.toLowerCase()) +} + +export const quoteMySqlIdentifierIfNeeded = (value: string) => { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + return trimmed + .split('.') + .map((part) => { + const segment = part.trim() + if (!segment || segment === '*') return segment + if (segment.startsWith('`') && segment.endsWith('`') && segment.length >= 2) return segment + if (!shouldQuoteMySqlIdentifier(segment)) return segment + return `\`${escapeMySqlIdentifier(segment)}\`` + }) + .join('.') +} diff --git a/frontend/src/modules/sql/pagination.test.ts b/frontend/src/modules/sql/pagination.test.ts new file mode 100644 index 0000000..85bf0fb --- /dev/null +++ b/frontend/src/modules/sql/pagination.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { + appendLimitOffset, + extractTopLevelLimit, + hasTopLevelLimit, + hasTopLevelOrderBy, + hasTopLevelWhere, + isLimitBeforeOrderBy, + needsDefaultPagination, + topLevelOrderByIndex, + stripTopLevelLimitClause, + stripSqlStatementTerminator, +} from './pagination' + +describe('SQL pagination helpers', () => { + it('strips trailing semicolons', () => { + expect(stripSqlStatementTerminator('SELECT 1;')).toBe('SELECT 1') + expect(stripSqlStatementTerminator('SELECT 1;; \n')).toBe('SELECT 1') + expect(stripSqlStatementTerminator('SELECT 1')).toBe('SELECT 1') + }) + + it('detects top-level LIMIT', () => { + expect(hasTopLevelLimit('SELECT * FROM t LIMIT 10')).toBe(true) + expect(hasTopLevelLimit("SELECT * FROM t WHERE note = 'limit 10'")).toBe(false) + expect(hasTopLevelLimit('SELECT * FROM (SELECT * FROM t LIMIT 10) x')).toBe(false) + }) + + it('extracts top-level LIMIT values', () => { + expect(extractTopLevelLimit('SELECT * FROM t LIMIT 200')).toBe(200) + expect(extractTopLevelLimit('SELECT * FROM t LIMIT 10, 50')).toBe(50) + expect(extractTopLevelLimit('SELECT * FROM t LIMIT 50 OFFSET 10')).toBe(50) + expect(extractTopLevelLimit('SELECT * FROM t WHERE note = \"limit 10\"')).toBe(null) + expect(extractTopLevelLimit('SELECT * FROM (SELECT * FROM t LIMIT 10) x')).toBe(null) + expect(extractTopLevelLimit('SELECT * FROM t LIMIT ?')).toBe(null) + }) + + it('detects LIMIT placed before ORDER BY', () => { + expect(isLimitBeforeOrderBy('SELECT * FROM t LIMIT 50 ORDER BY id desc')).toBe(true) + expect(isLimitBeforeOrderBy('SELECT * FROM t ORDER BY id desc LIMIT 50')).toBe(false) + }) + + it('appends LIMIT/OFFSET while preserving existing ORDER BY', () => { + expect(appendLimitOffset('SELECT * FROM t', { limit: 51, offset: 0 })).toBe( + 'SELECT * FROM t LIMIT 51 OFFSET 0' + ) + expect(appendLimitOffset('SELECT * FROM t;', { limit: 51, offset: 50 })).toBe( + 'SELECT * FROM t LIMIT 51 OFFSET 50' + ) + expect(appendLimitOffset('SELECT * FROM t ORDER BY id DESC', { limit: 51, offset: 0 })).toBe( + 'SELECT * FROM t ORDER BY id DESC LIMIT 51 OFFSET 0' + ) + }) + + it('strips top-level LIMIT clauses', () => { + expect(stripTopLevelLimitClause('SELECT * FROM t LIMIT 10')).toBe('SELECT * FROM t') + expect(stripTopLevelLimitClause('SELECT * FROM t ORDER BY id DESC LIMIT 10 OFFSET 5')).toBe( + 'SELECT * FROM t ORDER BY id DESC' + ) + expect(stripTopLevelLimitClause('SELECT * FROM (SELECT * FROM t LIMIT 5) x')).toBe( + 'SELECT * FROM (SELECT * FROM t LIMIT 5) x' + ) + }) + + it('detects top-level ORDER BY and WHERE', () => { + expect(hasTopLevelOrderBy('SELECT * FROM t ORDER BY id')).toBe(true) + expect(hasTopLevelOrderBy('SELECT * FROM (SELECT * FROM t ORDER BY id) x')).toBe(false) + expect(hasTopLevelWhere('SELECT * FROM t WHERE a = 1')).toBe(true) + expect(hasTopLevelWhere('SELECT * FROM (SELECT * FROM t WHERE a = 1) x')).toBe(false) + expect(topLevelOrderByIndex('SELECT * FROM t ORDER BY id DESC')).toBeGreaterThan(0) + expect(topLevelOrderByIndex('SELECT * FROM (SELECT * FROM t ORDER BY id) x')).toBe(-1) + }) + + it('enables default pagination only for SELECT/WITH without top-level limit', () => { + expect(needsDefaultPagination('SELECT * FROM t')).toBe(true) + expect(needsDefaultPagination('WITH cte AS (SELECT 1) SELECT * FROM cte')).toBe(true) + expect(needsDefaultPagination('SELECT * FROM t LIMIT 10')).toBe(false) + expect(needsDefaultPagination('UPDATE t SET a = 1')).toBe(false) + }) +}) diff --git a/frontend/src/modules/sql/pagination.ts b/frontend/src/modules/sql/pagination.ts new file mode 100644 index 0000000..c667159 --- /dev/null +++ b/frontend/src/modules/sql/pagination.ts @@ -0,0 +1,204 @@ +type SqlToken = { value: string; start: number; end: number } + +const isWordStart = (ch: string) => /[A-Za-z_]/.test(ch) +const isWordChar = (ch: string) => /[A-Za-z0-9_]/.test(ch) + +export function stripSqlStatementTerminator(statement: string): string { + let out = (statement || '').trimEnd() + while (out.endsWith(';')) { + out = out.slice(0, -1).trimEnd() + } + return out +} + +const scanTopLevelTokens = (statement: string): SqlToken[] => { + const sql = statement || '' + const tokens: SqlToken[] = [] + + let depth = 0 + let inSingle = false + let inDouble = false + let inBacktick = false + let inLineComment = false + let inBlockComment = false + + for (let i = 0; i < sql.length; i += 1) { + const ch = sql[i] ?? '' + const next = sql[i + 1] ?? '' + + if (inLineComment) { + if (ch === '\n') inLineComment = false + continue + } + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false + i += 1 + } + continue + } + + if (inSingle) { + if (ch === "'" && next === "'") { + i += 1 + continue + } + if (ch === "'" && sql[i - 1] !== '\\') { + inSingle = false + } + continue + } + + if (inDouble) { + if (ch === '"' && next === '"') { + i += 1 + continue + } + if (ch === '"' && sql[i - 1] !== '\\') { + inDouble = false + } + continue + } + + if (inBacktick) { + if (ch === '`') { + inBacktick = false + } + continue + } + + if (ch === '-' && next === '-' && /\s/.test(sql[i + 2] ?? '')) { + inLineComment = true + i += 1 + continue + } + if (ch === '#') { + inLineComment = true + continue + } + if (ch === '/' && next === '*') { + inBlockComment = true + i += 1 + continue + } + + if (ch === "'") { + inSingle = true + continue + } + if (ch === '"') { + inDouble = true + continue + } + if (ch === '`') { + inBacktick = true + continue + } + + if (ch === '(') { + depth += 1 + continue + } + if (ch === ')') { + depth = Math.max(0, depth - 1) + continue + } + + if (depth !== 0) continue + + if (!isWordStart(ch)) continue + + const start = i + let end = i + 1 + while (end < sql.length && isWordChar(sql[end] ?? '')) { + end += 1 + } + tokens.push({ value: sql.slice(start, end).toLowerCase(), start, end }) + i = end - 1 + } + + return tokens +} + +const firstTopLevelToken = (statement: string): string => { + const stripped = stripSqlStatementTerminator(statement).trimStart() + const tokens = scanTopLevelTokens(stripped) + return tokens[0]?.value ?? '' +} + +export function hasTopLevelLimit(statement: string): boolean { + const stripped = stripSqlStatementTerminator(statement) + return scanTopLevelTokens(stripped).some((t) => t.value === 'limit') +} + +export function extractTopLevelLimit(statement: string): number | null { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + const limitToken = tokens.find((t) => t.value === 'limit') + if (!limitToken) return null + const tail = stripped.slice(limitToken.end) + const match = tail.match(/^\s*(\d+)(?:\s*,\s*(\d+))?/) + if (!match) return null + const raw = match[2] ?? match[1] + if (!raw) return null + return Number(raw) +} + +export function stripTopLevelLimitClause(statement: string): string { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + const limitToken = tokens.find((t) => t.value === 'limit') + if (!limitToken) return stripped + return stripped.slice(0, limitToken.start).trimEnd() +} + +export function hasTopLevelOrderBy(statement: string): boolean { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + return tokens.some((token, idx) => token.value === 'order' && tokens[idx + 1]?.value === 'by') +} + +export function hasTopLevelWhere(statement: string): boolean { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + return tokens.some((t) => t.value === 'where') +} + +export function topLevelOrderByIndex(statement: string): number { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + const idx = tokens.findIndex((token, index) => token.value === 'order' && tokens[index + 1]?.value === 'by') + if (idx === -1) return -1 + return tokens[idx].start +} + +export function isLimitBeforeOrderBy(statement: string): boolean { + const stripped = stripSqlStatementTerminator(statement) + const tokens = scanTopLevelTokens(stripped) + const limitIndex = tokens.findIndex((t) => t.value === 'limit') + if (limitIndex === -1) return false + const orderIndex = tokens.findIndex((t, idx) => t.value === 'order' && tokens[idx + 1]?.value === 'by') + if (orderIndex === -1) return false + return limitIndex < orderIndex +} + +export function needsDefaultPagination(statement: string): boolean { + const first = firstTopLevelToken(statement) + if (first !== 'select' && first !== 'with') return false + if (hasTopLevelLimit(statement)) return false + return true +} + +export function appendLimitOffset( + statement: string, + opts: { + limit: number + offset: number + } +): string { + const stripped = stripSqlStatementTerminator(statement).trim() + if (!stripped) return stripped + if (hasTopLevelLimit(stripped)) return stripped + return `${stripped} LIMIT ${Math.max(0, Math.floor(opts.limit))} OFFSET ${Math.max(0, Math.floor(opts.offset))}` +} diff --git a/frontend/src/modules/sql/postgres.ts b/frontend/src/modules/sql/postgres.ts new file mode 100644 index 0000000..a1ac582 --- /dev/null +++ b/frontend/src/modules/sql/postgres.ts @@ -0,0 +1,159 @@ +export function getPostgresHint() { + return 'Required: name, host, port (default 5432), username. Optional: database (defaults to postgres), options.sslmode.' +} + +const postgresKeywordsNeedingQuotes = new Set( + ` +all +analyse +analyze +and +any +array +as +asc +asymmetric +authorization +between +binary +both +case +cast +check +collate +column +constraint +create +current_catalog +current_date +current_role +current_schema +current_time +current_timestamp +current_user +default +deferrable +desc +distinct +do +else +end +except +false +for +foreign +from +grant +group +having +in +initially +intersect +into +leading +limit +localtime +localtimestamp +not +null +offset +on +only +or +order +placing +primary +references +returning +select +session_user +some +symmetric +table +then +to +trailing +true +union +unique +user +using +variadic +when +where +window +with +`.trim().split(/\s+/), +) + +const escapePostgresIdentifier = (value: string) => value.replaceAll('"', '""') +const isSimplePostgresIdentifier = (value: string) => /^[a-z_][a-z0-9_$]*$/.test(value) + +const shouldQuotePostgresIdentifier = (value: string) => { + if (!isSimplePostgresIdentifier(value)) return true + return postgresKeywordsNeedingQuotes.has(value.toLowerCase()) +} + +const splitPostgresIdentifierPath = (value: string) => { + const out: string[] = [] + let current = '' + let inQuotedIdentifier = false + + for (let i = 0; i < value.length; i += 1) { + const ch = value[i] + if (ch === '"') { + current += ch + if (inQuotedIdentifier) { + const next = value[i + 1] + if (next === '"') { + current += next + i += 1 + } else { + inQuotedIdentifier = false + } + } else { + inQuotedIdentifier = true + } + continue + } + if (ch === '.' && !inQuotedIdentifier) { + out.push(current) + current = '' + continue + } + current += ch + } + + out.push(current) + return out +} + +type PostgresIdentifierQuoteOptions = { + treatDotAsPath?: boolean +} + +export const quotePostgresIdentifierIfNeeded = ( + value: string, + options: PostgresIdentifierQuoteOptions = {}, +) => { + const trimmed = String(value || '').trim() + if (!trimmed) return trimmed + + const treatDotAsPath = options.treatDotAsPath !== false + if (!treatDotAsPath) { + if (trimmed === '*') return trimmed + if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) return trimmed + if (!shouldQuotePostgresIdentifier(trimmed)) return trimmed + return `"${escapePostgresIdentifier(trimmed)}"` + } + + return splitPostgresIdentifierPath(trimmed) + .map((part) => { + const segment = part.trim() + if (!segment || segment === '*') return segment + if (segment.startsWith('"') && segment.endsWith('"') && segment.length >= 2) return segment + if (!shouldQuotePostgresIdentifier(segment)) return segment + return `"${escapePostgresIdentifier(segment)}"` + }) + .join('.') +} diff --git a/frontend/src/modules/sql/syntax-precheck.test.ts b/frontend/src/modules/sql/syntax-precheck.test.ts new file mode 100644 index 0000000..65ce2e6 --- /dev/null +++ b/frontend/src/modules/sql/syntax-precheck.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest' +import { applyPrecheckFix, precheckSql } from './syntax-precheck' + +describe('precheckSql', () => { + it('returns no issues for a clean SELECT statement', () => { + const issues = precheckSql("SELECT * FROM users WHERE id = '100000';") + expect(issues).toEqual([]) + }) + + it('returns no issues for empty input', () => { + expect(precheckSql('')).toEqual([]) + expect(precheckSql(' \n ')).toEqual([]) + }) + + it('detects an unclosed single quote and offers to close it', () => { + const sql = "SELECT * FROM users WHERE id = '100000" + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + const issue = issues[0] + expect(issue.kind).toBe('unclosed_single_quote') + expect(issue.severity).toBe('error') + expect(issue.startOffset).toBe(sql.indexOf("'")) + expect(issue.endOffset).toBe(sql.length) + expect(issue.fix?.replacement).toBe("'") + expect(applyPrecheckFix(sql, issue)).toBe(sql + "'") + }) + + it('detects an unclosed double quote', () => { + const sql = 'SELECT name FROM "users WHERE id = 1' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unclosed_double_quote') + expect(issues[0].fix?.replacement).toBe('"') + }) + + it('detects an unclosed backtick', () => { + const sql = 'SELECT `name FROM users' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unclosed_backtick') + expect(issues[0].fix?.replacement).toBe('`') + }) + + it('detects an unclosed block comment', () => { + const sql = 'SELECT 1 /* unfinished\nWHERE id = 1' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unclosed_block_comment') + expect(issues[0].fix?.replacement).toBe('*/') + }) + + it('does not flag a comment that closes properly', () => { + const sql = 'SELECT 1 /* inline */ FROM t' + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag content inside a MySQL # line comment', () => { + // `# )` is a MySQL hash comment, not an unbalanced paren. + const sql = 'SELECT 1 # )' + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag content inside a -- line comment', () => { + const sql = 'SELECT 1 -- ()),,, junk\nFROM t' + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag escaped quote inside a single-quoted string', () => { + const sql = "SELECT 'O\\'Brien' FROM users" + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag apostrophes inside a PG dollar-quoted string', () => { + const sql = "SELECT $$it's fine$$;" + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag apostrophes inside a tagged PG dollar-quoted string', () => { + const sql = "DO $body$ BEGIN RAISE NOTICE 'hi there'; END $body$;" + expect(precheckSql(sql)).toEqual([]) + }) + + it('detects an unclosed PG dollar-quoted string', () => { + const sql = "SELECT $$unfinished" + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unclosed_dollar_quote') + expect(issues[0].fix?.replacement).toBe('$$') + expect(issues[0].fix?.labelKey).toBe('console.precheck.fix.closeDollarQuote') + }) + + it('detects an unclosed tagged PG dollar-quoted string', () => { + const sql = "DO $body$ BEGIN" + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unclosed_dollar_quote') + expect(issues[0].fix?.replacement).toBe('$body$') + }) + + it('does not flag doubled single quotes inside string', () => { + const sql = "SELECT 'it''s fine' FROM t" + expect(precheckSql(sql)).toEqual([]) + }) + + it('detects an unbalanced open paren', () => { + const sql = 'SELECT * FROM users WHERE id IN (1, 2' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unbalanced_paren_open') + expect(issues[0].fix?.replacement).toBe(')') + }) + + it('detects an unbalanced close paren', () => { + const sql = 'SELECT * FROM users) WHERE id = 1' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('unbalanced_paren_close') + }) + + it('ignores parens inside string literals', () => { + const sql = "SELECT '(((' FROM t WHERE name = ')))'" + expect(precheckSql(sql)).toEqual([]) + }) + + it('detects a dangling comma before FROM', () => { + const sql = 'SELECT a, b, FROM users' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('dangling_comma') + }) + + it('detects a dangling comma before closing paren', () => { + const sql = 'INSERT INTO t (a, b, ) VALUES (1, 2, 3)' + const issues = precheckSql(sql) + expect(issues.some((i) => i.kind === 'dangling_comma')).toBe(true) + }) + + it('detects a dangling comma before semicolon', () => { + const sql = 'SELECT a, b,;' + const issues = precheckSql(sql) + expect(issues.some((i) => i.kind === 'dangling_comma')).toBe(true) + }) + + it('detects a dangling comma at end of statement (no terminator)', () => { + const sql = 'SELECT a, b,' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].kind).toBe('dangling_comma') + }) + + it('detects a dangling comma at end of statement with trailing whitespace', () => { + const sql = 'SELECT a, b, \n ' + const issues = precheckSql(sql) + expect(issues.some((i) => i.kind === 'dangling_comma')).toBe(true) + }) + + it('does not flag commas inside string literals', () => { + const sql = "SELECT ',' FROM t WHERE name = ','" + expect(precheckSql(sql)).toEqual([]) + }) + + it('does not flag legitimate comma in column list', () => { + const sql = 'SELECT a, b, c FROM users WHERE id = 1' + expect(precheckSql(sql)).toEqual([]) + }) + + it('returns line and column for the issue', () => { + const sql = "SELECT *\nFROM t\nWHERE id = '100" + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(issues[0].startLine).toBe(3) + expect(issues[0].startColumn).toBe(12) + }) + + it('reports multiple issues independently', () => { + const sql = "SELECT a, b, FROM t WHERE x = '1" + const issues = precheckSql(sql) + const kinds = issues.map((i) => i.kind).sort() + expect(kinds).toEqual(['dangling_comma', 'unclosed_single_quote']) + }) +}) + +describe('applyPrecheckFix', () => { + it('applies the suggested insert', () => { + const sql = "SELECT * FROM t WHERE id = '1" + const [issue] = precheckSql(sql) + expect(applyPrecheckFix(sql, issue)).toBe(`${sql}'`) + }) + + it('is a noop when there is no fix', () => { + const sql = 'SELECT * FROM t)' + const issues = precheckSql(sql) + expect(issues).toHaveLength(1) + expect(applyPrecheckFix(sql, issues[0])).toBe(sql) + }) + + it('removes the comma for dangling-comma fix before FROM', () => { + const sql = 'SELECT a, b, FROM users' + const [issue] = precheckSql(sql) + expect(issue.kind).toBe('dangling_comma') + expect(applyPrecheckFix(sql, issue)).toBe('SELECT a, b FROM users') + }) + + it('removes the comma for dangling-comma fix before closing paren', () => { + const sql = 'INSERT INTO t (a, b, ) VALUES (1, 2, 3)' + const [issue] = precheckSql(sql) + expect(issue.kind).toBe('dangling_comma') + expect(applyPrecheckFix(sql, issue)).toBe('INSERT INTO t (a, b ) VALUES (1, 2, 3)') + }) + + it('removes the comma for dangling-comma fix before semicolon', () => { + const sql = 'SELECT a, b,;' + const [issue] = precheckSql(sql) + expect(issue.kind).toBe('dangling_comma') + expect(applyPrecheckFix(sql, issue)).toBe('SELECT a, b;') + }) +}) diff --git a/frontend/src/modules/sql/syntax-precheck.ts b/frontend/src/modules/sql/syntax-precheck.ts new file mode 100644 index 0000000..86c194b --- /dev/null +++ b/frontend/src/modules/sql/syntax-precheck.ts @@ -0,0 +1,432 @@ +export type PrecheckSeverity = 'error' | 'warning' + +export type PrecheckIssueKind = + | 'unclosed_single_quote' + | 'unclosed_double_quote' + | 'unclosed_backtick' + | 'unclosed_dollar_quote' + | 'unclosed_block_comment' + | 'unbalanced_paren_open' + | 'unbalanced_paren_close' + | 'dangling_comma' + +export type PrecheckIssue = { + kind: PrecheckIssueKind + severity: PrecheckSeverity + messageKey: string + startOffset: number + endOffset: number + startLine: number + startColumn: number + endLine: number + endColumn: number + fix?: { + replaceStart: number + replaceEnd: number + replacement: string + labelKey: string + } +} + +type Position = { line: number; column: number } + +const offsetToPosition = (text: string, offset: number): Position => { + let line = 1 + let column = 1 + for (let i = 0; i < offset && i < text.length; i += 1) { + if (text[i] === '\n') { + line += 1 + column = 1 + } else { + column += 1 + } + } + return { line, column } +} + +const CLAUSE_KEYWORDS = new Set([ + 'from', 'where', 'group', 'order', 'having', 'limit', 'offset', + 'union', 'intersect', 'except', 'returning', +]) + +const isWordChar = (ch: string) => /[A-Za-z0-9_]/.test(ch) + +export function precheckSql(rawStatement: string): PrecheckIssue[] { + const sql = rawStatement || '' + const issues: PrecheckIssue[] = [] + if (!sql.trim()) return issues + + let inSingle = false + let inDouble = false + let inBacktick = false + let inLineComment = false + let inBlockComment = false + let inDollarTag: string | null = null + let singleStart = -1 + let doubleStart = -1 + let backtickStart = -1 + let blockCommentStart = -1 + let dollarStart = -1 + const parenStack: number[] = [] + + // PG dollar quotes: $tag$ ... $tag$ (tag is optional, [A-Za-z_][A-Za-z0-9_]*) + const matchDollarTag = (offset: number): { tag: string; length: number } | null => { + if (sql[offset] !== '$') return null + let j = offset + 1 + while (j < len && /[A-Za-z0-9_]/.test(sql[j])) j += 1 + if (sql[j] !== '$') return null + return { tag: sql.slice(offset + 1, j), length: j - offset + 1 } + } + + let lastNonSpaceOffset = -1 + let lastNonSpaceChar = '' + + const len = sql.length + for (let i = 0; i < len; i += 1) { + const ch = sql[i] + const next = sql[i + 1] ?? '' + + if (inLineComment) { + if (ch === '\n') inLineComment = false + continue + } + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false + i += 1 + } + continue + } + + if (inDollarTag !== null) { + // Look for matching closing $tag$ — apostrophes inside are literal. + if (ch === '$') { + const candidate = matchDollarTag(i) + if (candidate && candidate.tag === inDollarTag) { + inDollarTag = null + dollarStart = -1 + i += candidate.length - 1 + } + } + continue + } + + if (inSingle) { + if (ch === '\\' && next) { + i += 1 + continue + } + if (ch === "'" && next === "'") { + i += 1 + continue + } + if (ch === "'") { + inSingle = false + singleStart = -1 + } + continue + } + + if (inDouble) { + if (ch === '\\' && next) { + i += 1 + continue + } + if (ch === '"' && next === '"') { + i += 1 + continue + } + if (ch === '"') { + inDouble = false + doubleStart = -1 + } + continue + } + + if (inBacktick) { + if (ch === '`') { + inBacktick = false + backtickStart = -1 + } + continue + } + + if (ch === '$') { + const opener = matchDollarTag(i) + if (opener) { + inDollarTag = opener.tag + dollarStart = i + lastNonSpaceOffset = i + lastNonSpaceChar = ch + i += opener.length - 1 + continue + } + } + + if (ch === '-' && next === '-') { + inLineComment = true + i += 1 + continue + } + + // MySQL supports `#` line comments. PG/D1 don't, but a stray `#` in those + // dialects is already a syntax error the backend will catch — making this + // dialect-agnostic keeps the lexer simple and avoids dialect plumbing. + if (ch === '#') { + inLineComment = true + continue + } + + if (ch === '/' && next === '*') { + inBlockComment = true + blockCommentStart = i + i += 1 + continue + } + + if (ch === "'") { + inSingle = true + singleStart = i + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (ch === '"') { + inDouble = true + doubleStart = i + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (ch === '`') { + inBacktick = true + backtickStart = i + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (ch === '(') { + parenStack.push(i) + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (ch === ')') { + if (parenStack.length === 0) { + const pos = offsetToPosition(sql, i) + issues.push({ + kind: 'unbalanced_paren_close', + severity: 'error', + messageKey: 'console.precheck.unbalancedParenClose', + startOffset: i, + endOffset: i + 1, + startLine: pos.line, + startColumn: pos.column, + endLine: pos.line, + endColumn: pos.column + 1, + }) + } else { + parenStack.pop() + } + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (ch === ',') { + let j = i + 1 + while (j < len && /\s/.test(sql[j])) j += 1 + // j === len covers `SELECT a, b,` (trailing comma at statement end). + const atEnd = j >= len + let looksLikeClauseAhead = false + let looksLikeCloseOrEnd = false + if (!atEnd) { + let wordEnd = j + while (wordEnd < len && isWordChar(sql[wordEnd])) wordEnd += 1 + const nextWord = sql.slice(j, wordEnd).toLowerCase() + const nextChar = sql[j] + looksLikeClauseAhead = Boolean(nextWord && CLAUSE_KEYWORDS.has(nextWord)) + looksLikeCloseOrEnd = nextChar === ')' || nextChar === ';' + } + if (atEnd || looksLikeClauseAhead || looksLikeCloseOrEnd) { + const pos = offsetToPosition(sql, i) + issues.push({ + kind: 'dangling_comma', + severity: 'error', + messageKey: 'console.precheck.danglingComma', + startOffset: i, + endOffset: i + 1, + startLine: pos.line, + startColumn: pos.column, + endLine: pos.line, + endColumn: pos.column + 1, + fix: { + replaceStart: i, + replaceEnd: i + 1, + replacement: '', + labelKey: 'console.precheck.fix.removeComma', + }, + }) + } + lastNonSpaceOffset = i + lastNonSpaceChar = ch + continue + } + + if (!/\s/.test(ch)) { + lastNonSpaceOffset = i + lastNonSpaceChar = ch + } + } + + if (inSingle && singleStart >= 0) { + const startPos = offsetToPosition(sql, singleStart) + const endPos = offsetToPosition(sql, len) + issues.push({ + kind: 'unclosed_single_quote', + severity: 'error', + messageKey: 'console.precheck.unclosedSingleQuote', + startOffset: singleStart, + endOffset: len, + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: "'", + labelKey: 'console.precheck.fix.closeSingleQuote', + }, + }) + } + + if (inDouble && doubleStart >= 0) { + const startPos = offsetToPosition(sql, doubleStart) + const endPos = offsetToPosition(sql, len) + issues.push({ + kind: 'unclosed_double_quote', + severity: 'error', + messageKey: 'console.precheck.unclosedDoubleQuote', + startOffset: doubleStart, + endOffset: len, + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: '"', + labelKey: 'console.precheck.fix.closeDoubleQuote', + }, + }) + } + + if (inBacktick && backtickStart >= 0) { + const startPos = offsetToPosition(sql, backtickStart) + const endPos = offsetToPosition(sql, len) + issues.push({ + kind: 'unclosed_backtick', + severity: 'error', + messageKey: 'console.precheck.unclosedBacktick', + startOffset: backtickStart, + endOffset: len, + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: '`', + labelKey: 'console.precheck.fix.closeBacktick', + }, + }) + } + + if (inDollarTag !== null && dollarStart >= 0) { + const startPos = offsetToPosition(sql, dollarStart) + const endPos = offsetToPosition(sql, len) + const closingTag = `$${inDollarTag}$` + issues.push({ + kind: 'unclosed_dollar_quote', + severity: 'error', + messageKey: 'console.precheck.unclosedDollarQuote', + startOffset: dollarStart, + endOffset: len, + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: closingTag, + labelKey: 'console.precheck.fix.closeDollarQuote', + }, + }) + } + + if (inBlockComment && blockCommentStart >= 0) { + const startPos = offsetToPosition(sql, blockCommentStart) + const endPos = offsetToPosition(sql, len) + issues.push({ + kind: 'unclosed_block_comment', + severity: 'error', + messageKey: 'console.precheck.unclosedBlockComment', + startOffset: blockCommentStart, + endOffset: len, + startLine: startPos.line, + startColumn: startPos.column, + endLine: endPos.line, + endColumn: endPos.column, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: '*/', + labelKey: 'console.precheck.fix.closeBlockComment', + }, + }) + } + + for (const openOffset of parenStack) { + const startPos = offsetToPosition(sql, openOffset) + issues.push({ + kind: 'unbalanced_paren_open', + severity: 'error', + messageKey: 'console.precheck.unbalancedParenOpen', + startOffset: openOffset, + endOffset: openOffset + 1, + startLine: startPos.line, + startColumn: startPos.column, + endLine: startPos.line, + endColumn: startPos.column + 1, + fix: { + replaceStart: len, + replaceEnd: len, + replacement: ')', + labelKey: 'console.precheck.fix.closeParen', + }, + }) + } + + // Suppress lastNonSpaceChar tracking lint — kept for future "missing terminator" detection + void lastNonSpaceChar + void lastNonSpaceOffset + + return issues +} + +export function applyPrecheckFix(statement: string, issue: PrecheckIssue): string { + if (!issue.fix) return statement + const { replaceStart, replaceEnd, replacement } = issue.fix + const safeStart = Math.max(0, Math.min(statement.length, replaceStart)) + const safeEnd = Math.max(safeStart, Math.min(statement.length, replaceEnd)) + return statement.slice(0, safeStart) + replacement + statement.slice(safeEnd) +} diff --git a/frontend/src/modules/sql/templates.test.ts b/frontend/src/modules/sql/templates.test.ts new file mode 100644 index 0000000..9972116 --- /dev/null +++ b/frontend/src/modules/sql/templates.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import type { DescribeResult } from '@/types' + +import { buildSqlWriteTemplates, isMySqlExplainNoMatchingConstTable, sqlPrimaryKeyColumns } from './templates' + +describe('buildSqlWriteTemplates', () => { + it('uses real columns and primary key when detail is available', () => { + const detail: DescribeResult = { + columns: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'name', dataType: 'varchar', nullable: 'NO' }, + { name: 'created_at', dataType: 'datetime', nullable: 'NO' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id', unique: true }], + } + + const templates = buildSqlWriteTemplates(detail) + expect(templates.insert).toContain('INSERT INTO {{target}}') + expect(templates.insert).toContain('(name') + expect(templates.insert).not.toContain('column1') + + expect(templates.update).toContain('UPDATE {{target}}') + expect(templates.update).toContain('SET name') + expect(templates.update).toContain('WHERE id') + + expect(templates.delete).toContain('DELETE FROM {{target}}') + expect(templates.delete).toContain('WHERE id') + }) +}) + +describe('isMySqlExplainNoMatchingConstTable', () => { + it('detects MySQL const-table no match explain output', () => { + expect( + isMySqlExplainNoMatchingConstTable([ + { + type: 'NULL', + Extra: 'no matching row in const table', + }, + ]) + ).toBe(true) + }) +}) + +describe('sqlPrimaryKeyColumns', () => { + it('splits comma-separated primary key columns', () => { + const detail: DescribeResult = { + columns: [ + { name: 'id', dataType: 'int', nullable: 'NO' }, + { name: 'key', dataType: 'varchar', nullable: 'NO' }, + ], + indexes: [{ name: 'PRIMARY', column: 'id, key', unique: true }], + } + + expect(sqlPrimaryKeyColumns(detail)).toEqual(['id', 'key']) + }) + + it('extracts primary key columns in index order', () => { + const detail: DescribeResult = { + columns: [ + { name: 'a', dataType: 'int', nullable: 'NO' }, + { name: 'b', dataType: 'int', nullable: 'NO' }, + ], + indexes: [ + { name: 'PRIMARY', column: 'b', unique: true }, + { name: 'PRIMARY', column: 'a', unique: true }, + ], + } + + expect(sqlPrimaryKeyColumns(detail)).toEqual(['b', 'a']) + }) +}) diff --git a/frontend/src/modules/sql/templates.ts b/frontend/src/modules/sql/templates.ts new file mode 100644 index 0000000..862e679 --- /dev/null +++ b/frontend/src/modules/sql/templates.ts @@ -0,0 +1,135 @@ +import type { DescribeResult } from '@/types' + +export type SqlWriteTemplates = { + insert: string + update: string + delete: string +} + +const sqlDefaultTemplates: SqlWriteTemplates = { + insert: 'INSERT INTO {{target}} () VALUES ();', + update: 'UPDATE {{target}} SET = WHERE ;', + delete: 'DELETE FROM {{target}} WHERE ;', +} + +const normalizeColumnName = (name: string) => name.trim() + +const parseColumnList = (value: string) => + String(value || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => part.replaceAll('"', '').replaceAll('`', '')) + +const parseDefinitionColumns = (definition: string) => { + const start = definition.indexOf('(') + const end = definition.lastIndexOf(')') + if (start === -1 || end === -1 || end <= start) return [] + const inside = definition.slice(start + 1, end) + return inside + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => part.replaceAll('"', '').replaceAll('`', '')) +} + +export function sqlPrimaryKeyColumns(detail?: DescribeResult): string[] { + const seen = new Set() + const out: string[] = [] + for (const idx of detail?.indexes || []) { + const name = (idx.name || '').trim().toLowerCase() + if (name === 'primary' && idx.column) { + parseColumnList(idx.column).forEach((raw) => { + const col = normalizeColumnName(raw) + if (col && !seen.has(col)) { + seen.add(col) + out.push(col) + } + }) + continue + } + if (name.endsWith('_pkey') || name.endsWith('pkey')) { + if (idx.column) { + parseColumnList(idx.column).forEach((raw) => { + const col = normalizeColumnName(raw) + if (col && !seen.has(col)) { + seen.add(col) + out.push(col) + } + }) + } + if (idx.definition) { + parseDefinitionColumns(idx.definition).forEach((raw) => { + const col = normalizeColumnName(raw) + if (col && !seen.has(col)) { + seen.add(col) + out.push(col) + } + }) + } + } + } + return out +} + +const sqlValuePlaceholder = (dataType: string) => { + const lower = (dataType || '').toLowerCase() + if (!lower) return `'value'` + if (lower.includes('bool')) return '0' + if (lower.includes('int') || lower.includes('decimal') || lower.includes('numeric')) return '0' + if (lower.includes('float') || lower.includes('double')) return '0' + if (lower.includes('json')) return `'{}'` + if (lower.includes('uuid')) return `'00000000-0000-0000-0000-000000000000'` + if (lower.includes('timestamp') || lower.includes('datetime')) return 'CURRENT_TIMESTAMP' + if (lower.includes('date') && !lower.includes('time')) return `'2026-01-01'` + if (lower.includes('time')) return `'00:00:00'` + return `'value'` +} + +export function buildSqlWriteTemplates(detail?: DescribeResult): SqlWriteTemplates { + const columns = (detail?.columns || []) + .map((col) => ({ ...col, name: normalizeColumnName(col.name) })) + .filter((col) => col.name) + + if (!columns.length) return sqlDefaultTemplates + + const pkColumns = new Set(sqlPrimaryKeyColumns(detail)) + + const whereColumn = columns.find((col) => pkColumns.has(col.name)) || columns[0] + const whereValue = sqlValuePlaceholder(whereColumn.dataType) + + const updatable = columns.filter((col) => !pkColumns.has(col.name)) + const updateColumn = (updatable[0] || columns[0]) ?? null + const updateValue = updateColumn ? sqlValuePlaceholder(updateColumn.dataType) : '' + + const insertColumns = (updatable.length ? updatable : columns).slice(0, 3) + const insertColumnList = insertColumns.map((col) => col.name).join(', ') + const insertValueList = insertColumns.map((col) => sqlValuePlaceholder(col.dataType)).join(', ') + + return { + insert: `INSERT INTO {{target}} (${insertColumnList}) VALUES (${insertValueList});`, + update: `UPDATE {{target}} SET ${updateColumn ? updateColumn.name : ''} = ${updateValue} WHERE ${whereColumn.name} = ${whereValue};`, + delete: `DELETE FROM {{target}} WHERE ${whereColumn.name} = ${whereValue};`, + } +} + +const readExplainField = (row: Record, key: string): string => { + const direct = row[key] + if (typeof direct === 'string') return direct + if (direct === undefined || direct === null) return '' + return String(direct) +} + +export function isMySqlExplainNoMatchingConstTable(detail: unknown): boolean { + if (!Array.isArray(detail)) return false + for (const entry of detail) { + if (!entry || typeof entry !== 'object') continue + const row = entry as Record + const typ = readExplainField(row, 'type').trim().toUpperCase() + const extra = (readExplainField(row, 'Extra') || readExplainField(row, 'extra')).trim().toLowerCase() + if (typ === 'NULL') return true + if (extra.includes('no matching row in const table')) return true + if (extra.includes('impossible where')) return true + } + return false +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..dcf339a --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,123 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import DatasourceListView from '@/views/DatasourceListView.vue' +import DatasourceFormView from '@/views/DatasourceFormView.vue' +import ConsoleView from '@/views/ConsoleView.vue' +import HistoryView from '@/views/HistoryView.vue' +import SensitivityListView from '@/views/SensitivityListView.vue' +import RiskRulesView from '@/views/RiskRulesView.vue' +import RiskRulesFormView from '@/views/RiskRulesFormView.vue' +import AISettingsView from '@/views/AISettingsView.vue' +import AISettingsFormView from '@/views/AISettingsFormView.vue' +import MyView from '@/views/MyView.vue' +import SensitivityView from '@/views/SensitivityView.vue' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + name: 'datasources', + component: DatasourceListView, + meta: { titleKey: 'route.datasources' }, + }, + { + path: '/datasources/new', + name: 'datasource-create', + component: DatasourceFormView, + meta: { titleKey: 'route.datasourceCreate' }, + }, + { + path: '/datasources/:id/edit', + name: 'datasource-edit', + component: DatasourceFormView, + meta: { titleKey: 'route.datasourceEdit' }, + }, + { + path: '/console', + redirect: '/', + }, + { + path: '/console/:id', + name: 'console', + component: ConsoleView, + meta: { titleKey: 'route.console' }, + }, + { + path: '/history', + name: 'history', + component: HistoryView, + meta: { titleKey: 'route.history' }, + }, + { + path: '/sensitivity', + name: 'sensitivity-list', + component: SensitivityListView, + meta: { titleKey: 'route.sensitivityList' }, + }, + { + path: '/risk-rules', + name: 'risk-rules', + component: RiskRulesView, + meta: { titleKey: 'route.riskRules' }, + }, + { + path: '/risk-rules/new', + name: 'risk-rules-create', + component: RiskRulesFormView, + meta: { titleKey: 'route.riskRulesCreate' }, + }, + { + path: '/risk-rules/:id/edit', + name: 'risk-rules-edit', + component: RiskRulesFormView, + meta: { titleKey: 'route.riskRulesEdit' }, + }, + { + path: '/ai-settings', + name: 'ai-settings', + component: AISettingsView, + meta: { titleKey: 'route.aiSettings' }, + }, + { + path: '/ai-settings/new', + name: 'ai-settings-create', + component: AISettingsFormView, + meta: { titleKey: 'route.aiSettingsCreate' }, + }, + { + path: '/ai-settings/:id/edit', + name: 'ai-settings-edit', + component: AISettingsFormView, + meta: { titleKey: 'route.aiSettingsEdit' }, + }, + { + path: '/ai-settings/embedding/new', + name: 'ai-settings-embedding-create', + component: AISettingsFormView, + meta: { titleKey: 'route.aiSettingsCreate' }, + }, + { + path: '/ai-settings/embedding/:id/edit', + name: 'ai-settings-embedding-edit', + component: AISettingsFormView, + meta: { titleKey: 'route.aiSettingsEdit' }, + }, + { + path: '/sensitivity/:id', + name: 'sensitivity-detail', + component: SensitivityView, + meta: { titleKey: 'route.sensitivity' }, + }, + { + path: '/my', + name: 'my', + component: MyView, + meta: { titleKey: 'route.my' }, + }, + ], + scrollBehavior() { + return { left: 0, top: 0 } + }, +}) + +export default router diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..6ea33a1 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,29 @@ +import { aiApi } from './api/aiconfig' +import { aiChatApi } from './api/aichat' +import { authApi } from './api/auth' +import { consoleApi } from './api/console' +import { datasourcesApi } from './api/datasources' +import { embeddingApi } from './api/embedding' +import { historyApi } from './api/history' +import { logsApi } from './api/logs' +import { redisProtobufApi } from './api/redisProtobuf' +import { skillApi } from './api/skill' +import { startupRecoveryApi } from './api/startupRecovery' +import { updaterApi } from './api/updater' +import { userKBApi } from './api/userkb' + +export const api = { + ...datasourcesApi, + ...authApi, + ...consoleApi, + ...historyApi, + ...logsApi, + ...startupRecoveryApi, + ...aiApi, + ...aiChatApi, + ...embeddingApi, + ...userKBApi, + ...skillApi, + ...updaterApi, + ...redisProtobufApi, +} diff --git a/frontend/src/services/api/aichat.ts b/frontend/src/services/api/aichat.ts new file mode 100644 index 0000000..06ee448 --- /dev/null +++ b/frontend/src/services/api/aichat.ts @@ -0,0 +1,629 @@ +import { AiChatApprove, AiChatCancelStream, AiChatTurn, AiChatTurnStream } from '@wailsjs/go/main/App' +import type { aichat } from '@wailsjs/go/models' + +import { withMock } from './core' +import { newId } from './core' +import { loadMockState } from './mockState' + +const pending = new Map() +const lastResultByConversation = new Map() + +type TrustLevel = 'approval' | 'cautious' | 'trusted' | 'danger' + +const normalizeTrustLevel = (value: unknown): TrustLevel => { + if (value === 'approval' || value === 'cautious' || value === 'trusted' || value === 'danger') { + return value + } + return 'cautious' +} + +const canAutoExecuteAtTrust = (trust: TrustLevel, riskLevel: string) => { + if (trust === 'approval') return false + if (trust === 'danger') return true + if (trust === 'trusted') return riskLevel === 'low' || riskLevel === 'medium' + return riskLevel === 'low' +} + +const parseElasticsearchRequestShape = (statement: string) => { + const lines = String(statement || '').split('\n') + const firstLine = lines.find((line) => line.trim())?.trim() || '' + const [rawMethod = '', rawPath = ''] = firstLine.split(/\s+/, 2) + const method = rawMethod.toUpperCase() + if (!method || !rawPath) return null + const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}` + const bodyStart = lines.findIndex((line) => line.trim()) + const body = bodyStart >= 0 ? lines.slice(bodyStart + 1).join('\n').trim() : '' + return { method, path, body } +} + +const elasticsearchPathIsSearch = (path: string) => + path === '/_search' || path.endsWith('/_search') + +const elasticsearchQueryContainsBroadClause = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some((child) => elasticsearchQueryContainsBroadClause(child)) + if (!value || typeof value !== 'object') return false + + for (const [rawKey, child] of Object.entries(value as Record)) { + const key = rawKey.trim().toLowerCase() + if (key === 'match_all' || key === 'wildcard' || key === 'regexp') return true + if (elasticsearchQueryContainsBroadClause(child)) return true + } + + return false +} + +const elasticsearchStatementIsLowRisk = (statement: string) => { + const parsed = parseElasticsearchRequestShape(statement) + if (!parsed) return false + if ((parsed.method === 'GET' || parsed.method === 'HEAD') && parsed.path.includes('/_doc/')) { + return true + } + if (!elasticsearchPathIsSearch(parsed.path) || !parsed.body) return false + try { + const payload = JSON.parse(parsed.body) + const query = payload?.query + const size = Number(payload?.size || 0) + if (!query || typeof query !== 'object') return false + if (payload?.aggs || payload?.aggregations) return false + if (elasticsearchQueryContainsBroadClause(query)) return false + return size > 0 && size <= 100 + } catch { + return false + } +} + +const mockTurn = async (payload: aichat.TurnRequest): Promise => { + const last = String(payload.messages?.at(-1)?.content ?? '').toLowerCase() + const wantsCreate = last.includes('create datasource') || last.includes('创建数据源') || last.includes('新增数据源') + const wantsDelete = last.includes('delete datasource') || last.includes('删除数据源') + const wantsEnter = last.includes('enter') || last.includes('open') || last.includes('进入') || last.includes('打开') + const wantsVisualize = last.includes('visualize') + || last.includes('visualisation') + || last.includes('visualization') + || last.includes('chart') + || last.includes('plot') + || last.includes('可视化') + || last.includes('图表') + || last.includes('画图') + const wantsElastic = last.includes('elasticsearch') || last.includes('elastic') || String(payload.pageContext?.currentDatasourceType || '').toLowerCase().includes('elasticsearch') + const wantsPlanMode = + last.includes('plan this') + || last.includes('plan mode') + || last.includes('workflow') + || last.includes('multi-step') + || last.includes('多步骤') + || last.includes('分步骤') + || last.includes('计划执行') + const wantsExecute = + last.includes('执行') + || last.includes('run') + || last.includes('execute') + || last.includes('查询') + || last.includes('select ') + || last.includes('find(') + + if (wantsPlanMode) { + return { + assistantMessage: '', + agent: { + mode: 'plan_executor', + complexity: 'complex', + reason: 'The request requires a staged workflow with checkpoints.', + confidence: 0.86, + }, + plan: { + title: 'Execution Plan', + summary: 'Break the task into safe steps and iterate with validation.', + markdown: '1. Clarify target and constraints\\n2. Collect required context\\n3. Draft and verify actions\\n4. Execute and summarize outcome', + steps: [ + { id: 'step_1', title: 'Clarify target', description: 'Confirm business goal and output format.', status: 'completed' }, + { id: 'step_2', title: 'Collect context', description: 'Inspect datasource, schema, and recent results.', status: 'in_progress' }, + { id: 'step_3', title: 'Execute safely', description: 'Run approved actions and check side effects.', status: 'pending' }, + ], + }, + } as any + } + + if (wantsCreate) { + const approvalId = `appr_mock_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + const ds = wantsElastic + ? { + name: 'Mock ES', + type: 'elasticsearch', + host: '127.0.0.1', + port: 9200, + database: '', + username: '', + password: '', + } + : { + name: 'Mock DS', + type: 'mysql', + host: '127.0.0.1', + port: 3306, + database: 'appdb', + username: 'root', + password: '', + } + pending.set(approvalId, { kind: 'create_datasource', payload: ds }) + return { + assistantMessage: 'I can create a datasource for you. Please confirm.', + approval: { + id: approvalId, + kind: 'create_datasource', + summary: `Create datasource "${ds.name}" (${ds.type}) at ${ds.host}:${ds.port} database "${ds.database}"`, + payload: ds, + }, + } + } + + if (wantsDelete) { + const state = await loadMockState() + const target = state.datasources.at(-1) + if (!target) { + return { assistantMessage: 'No datasource found to delete (mock).' } + } + const approvalId = `appr_mock_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + pending.set(approvalId, { kind: 'delete_datasource', payload: { datasourceId: target.id, name: target.name } }) + return { + assistantMessage: `I can delete "${target.name}". Please confirm.`, + approval: { + id: approvalId, + kind: 'delete_datasource', + summary: `Delete datasource "${target.name}" (${target.id})`, + payload: { datasourceId: target.id, name: target.name }, + }, + } + } + + if (wantsEnter) { + const state = await loadMockState() + const lower = last.toLowerCase() + const byName = state.datasources.find((ds) => { + const name = String(ds.name || '').toLowerCase() + return name && lower.includes(name) + }) + const byType = !byName + ? state.datasources.find((ds) => lower.includes('mysql') && String(ds.type || '').toLowerCase().includes('mysql')) + || state.datasources.find((ds) => (lower.includes('mongo') || lower.includes('mongodb')) && String(ds.type || '').toLowerCase().includes('mongo')) + || state.datasources.find((ds) => lower.includes('redis') && String(ds.type || '').toLowerCase().includes('redis')) + || state.datasources.find((ds) => (lower.includes('elastic') || lower.includes('elasticsearch')) && String(ds.type || '').toLowerCase().includes('elastic')) + : null + const target = byName || byType || state.datasources[0] + if (target?.id) { + return { + assistantMessage: `Opening \`${target.name}\` in Console...`, + effects: { navigateTo: `/console/${target.id}` }, + } + } + } + + if (wantsVisualize) { + const convoId = String(payload.conversationId || '') + const stored = convoId ? lastResultByConversation.get(convoId) : null + const result = stored?.result + const rows = Array.isArray(result?.rows) ? result.rows : [] + const approvalId = `appr_mock_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + + if (!stored || !rows.length) { + return { assistantMessage: 'No recent query result available to visualize (mock). Run a query first.' } + } + + const approxBytes = (() => { + try { return JSON.stringify(rows).length } catch { return 0 } + })() + + pending.set(approvalId, { kind: 'create_visualization', payload: { conversationId: convoId, question: last } }) + return { + assistantMessage: 'I can generate a visualization from the last result. Please confirm.', + approval: { + id: approvalId, + kind: 'create_visualization', + summary: `Send ${rows.length} rows to AI to generate visualization (mock)`, + payload: { + datasourceId: String(stored.datasourceId || ''), + datasourceType: String(stored.datasourceType || ''), + database: String(stored.database || ''), + rowCount: Number(result?.rowCount || rows.length), + payloadRows: rows.length, + truncated: false, + approxBytes, + capturedAt: new Date().toISOString(), + }, + }, + } + } + + if (wantsExecute) { + const state = await loadMockState() + const currentId = String(payload.pageContext?.currentDatasourceId ?? '') + const currentName = String(state.datasources.find((ds) => ds.id === currentId)?.name ?? '') + const dsId = currentId || state.datasources[0]?.id || '' + const dsLabel = currentName || state.datasources.find((ds) => ds.id === dsId)?.name || dsId || 'datasource' + const dsRecord = state.datasources.find((ds) => ds.id === dsId) + const trustLevel = normalizeTrustLevel((dsRecord?.options as any)?.trustLevel) + + const currentDatasourceType = String(payload.pageContext?.currentDatasourceType || '').toLowerCase() + const isMongo = last.includes('mongo') || last.includes('mongodb') || currentDatasourceType.includes('mongo') + const isRedis = last.includes('redis') || currentDatasourceType.includes('redis') + const isElastic = wantsElastic || currentDatasourceType.includes('elastic') + const isChroma = last.includes('chromadb') || last.includes('chroma') || currentDatasourceType.includes('chromadb') + const wantsRedisGet = isRedis && /\bget\b/.test(last) + const wantsRedisFlushAll = isRedis && (last.includes('flushall') || last.includes('flush all')) + const wantsRedisFlushDb = isRedis && last.includes('flushdb') + const isNoIndex = last.includes('no index') || last.includes('无索引') || last.includes('不走索引') + const isLarge = last.includes('>1000') || last.includes('大于1000') || last.includes('2000') || last.includes('large') + const usesIndex = !isNoIndex + const examined = isLarge ? 2000 : 100 + const isWrite = last.includes('delete') + || last.includes('删除') + || last.includes('删掉') + || last.includes('insert') + || last.includes('新增') + || last.includes('add row') + || last.includes('update') + || last.includes('更新') + + let statement = isMongo + ? `{"action":"find","collection":"files","filter":{},"options":{"sort":{"_id":1},"projection":{"_id":1,"size":1},"limit":100}}` + : isRedis + ? (wantsRedisFlushAll ? 'FLUSHALL' : wantsRedisFlushDb ? 'FLUSHDB' : wantsRedisGet ? 'GET key' : 'SCAN 0 MATCH * COUNT 100') + : isElastic + ? (last.includes('_search') || last.includes('search') || last.includes('查询') + ? 'POST /futrixdata-demo-1/_search\n{"query":{"match_all":{}},"size":10}' + : 'GET /_cat/indices?v') + : isChroma + ? (last.includes('query') || last.includes('search') || last.includes('查询') + ? 'POST /collections/futrix_docs/query\n{"query_texts":["futrixdata"],"n_results":5,"include":["documents","metadatas"]}' + : 'POST /collections/futrix_docs/get\n{"limit":50,"include":["documents","metadatas"]}') + : isLarge + ? 'SELECT * FROM table_name ORDER BY id ASC LIMIT 2000;' + : 'SELECT * FROM table_name ORDER BY id ASC LIMIT 100;' + + if (!isMongo && !isRedis && !isChroma) { + if (last.includes('drop')) { + statement = 'DROP TABLE table_name;' + } else if (last.includes('truncate')) { + statement = 'TRUNCATE TABLE table_name;' + } else if (last.includes('delete') || last.includes('删除') || last.includes('删掉')) { + statement = 'DELETE FROM table_name WHERE id = 1;' + } else if (last.includes('insert') || last.includes('新增') || last.includes('add row')) { + statement = "INSERT INTO table_name (id, name) VALUES (1, 'Alice');" + } else if (last.includes('update') || last.includes('更新')) { + statement = "UPDATE table_name SET name = 'Alice' WHERE id = 1;" + } + } + + if (isMongo && (isLarge || isNoIndex)) { + const pad = 'x'.repeat(1200) + statement = `{"action":"find","collection":"files","filter":{},"options":{"sort":{"_id":1},"projection":{"_id":1,"size":1},"limit":100,"comment":"${pad}"}}` + } + + const executionRisk = (() => { + if (isRedis) { + const cmd = statement.trim().split(/\s+/)[0]?.toUpperCase() || '' + if (cmd === 'FLUSHALL' || cmd === 'FLUSHDB' || cmd === 'SHUTDOWN') return { level: 'high', reasons: [cmd] } + if (cmd === 'DEL') return { level: 'medium', reasons: ['DEL'] } + if (cmd === 'GET') return { level: 'low', reasons: [] } + return { level: 'medium', reasons: ['SCAN'] } + } + if (isElastic) { + const parsed = parseElasticsearchRequestShape(statement) + const method = parsed?.method || '' + if (method === 'DELETE') return { level: 'high', reasons: ['DELETE'] } + if (method === 'PUT' || method === 'PATCH') return { level: 'medium', reasons: [method] } + if (elasticsearchStatementIsLowRisk(statement)) return { level: 'low', reasons: [] } + if (method === 'POST') return { level: 'medium', reasons: ['REQUEST_SCOPE'] } + return { level: 'medium', reasons: ['REQUEST_SCOPE'] } + } + if (isChroma) { + if (statement.startsWith('POST /collections/') && (statement.includes('/get') || statement.includes('/query'))) { + return { level: 'low', reasons: [] } + } + return { level: 'medium', reasons: ['REQUEST_SCOPE'] } + } + const keyword = statement.trim().split(/\s+/)[0]?.toLowerCase() || '' + if (keyword === 'drop' || keyword === 'truncate') return { level: 'high', reasons: ['destructive DDL'] } + if (keyword === 'delete') return { level: 'medium', reasons: ['DELETE'] } + if (keyword === 'insert' || keyword === 'replace') return { level: 'medium', reasons: ['INSERT/REPLACE'] } + if (keyword === 'update') return { level: 'medium', reasons: ['UPDATE'] } + if (!usesIndex || examined > 1000 || isWrite) { + return { level: 'medium', reasons: !usesIndex ? ['NO_INDEX'] : examined > 1000 ? ['WIDE_SCAN'] : ['WRITE'] } + } + return { level: 'low', reasons: [] } + })() + const autoExec = canAutoExecuteAtTrust(trustLevel, executionRisk.level) + + if (autoExec) { + const result = { + columns: ['id', 'name'], + rows: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + rowCount: 3, + elapsedMs: 12, + hasMore: false, + } + + lastResultByConversation.set(String(payload.conversationId || ''), { + datasourceId: dsId, + datasourceType: isMongo ? 'mongodb' : isRedis ? 'redis' : isElastic ? 'elasticsearch' : isChroma ? 'chromadb' : 'mysql', + database: payload.pageContext?.currentDatabase || '', + statement, + result, + }) + + return { + assistantMessage: [ + `I ran this on \`${dsLabel}\`:`, + '', + isMongo ? '```json' : isRedis ? '```redis' : isElastic || isChroma ? '```text' : '```sql', + statement, + '```', + '', + '---', + '', + '### Execution check', + '', + usesIndex ? '- EXPLAIN: ✅ uses index (mock)' : '- EXPLAIN: ⚠️ no index detected (mock)', + `- Examined: keys=${examined} docs=${examined}`, + '', + `Auto-executed because ${executionRisk.level} risk is enabled in preferences (mock).`, + '', + '### Execution result (mock)', + '', + '- Rows returned: 3', + '- Columns: 2', + '', + '_Results are shown in the Console results panel._', + ].join('\n'), + effects: { + consoleResult: { + datasourceId: dsId, + datasourceType: isMongo ? 'mongodb' : isRedis ? 'redis' : isElastic ? 'elasticsearch' : isChroma ? 'chromadb' : 'mysql', + database: payload.pageContext?.currentDatabase || '', + statement, + result, + }, + } as any, + } + } + + const approvalId = `appr_mock_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + const datasourceType = isMongo ? 'mongodb' : isRedis ? 'redis' : isElastic ? 'elasticsearch' : isChroma ? 'chromadb' : 'mysql' + const approvalExplain = (isRedis || isElastic || isChroma) + ? { explainSkipped: true } + : { + explain: { + usesIndex, + indexes: usesIndex ? ['idx_mock'] : [], + stages: usesIndex ? ['IXSCAN'] : ['COLLSCAN'], + totalKeysExamined: examined, + totalDocsExamined: examined, + }, + } + + const approvalPayload = { + datasourceId: dsId, + datasourceName: dsLabel, + datasourceType, + database: payload.pageContext?.currentDatabase || '', + statement, + risk: executionRisk, + trustLevel, + ...(approvalExplain as any), + } + + pending.set(approvalId, { kind: 'execute_statement', payload: { ...approvalPayload, pageSize: 100 } }) + return { + assistantMessage: [ + `I can run this on \`${dsLabel}\`:`, + '', + isMongo ? '```json' : isRedis ? '```redis' : isChroma ? '```text' : '```sql', + statement, + '```', + '', + '---', + '', + '### Execution check', + '', + usesIndex ? '- EXPLAIN: ✅ uses index (mock)' : '- EXPLAIN: ⚠️ no index detected (mock)', + `- Examined: keys=${examined} docs=${examined}`, + '', + 'Approve to execute, or Reject to cancel.', + ].join('\n'), + approval: { + id: approvalId, + kind: 'execute_statement', + summary: `Execute statement on "${dsLabel}"`, + payload: approvalPayload, + }, + } + } + + if (last.includes('redis')) { + return { + assistantMessage: [ + '```redis', + 'GET key', + '```', + '', + '- (mock) Replace `key` with your key name.', + ].join('\n'), + } + } + if (last.includes('mongo')) { + return { + assistantMessage: [ + '```javascript', + 'db.collection.find({}).limit(10)', + '```', + '', + '- (mock)', + ].join('\n'), + } + } + if (last.includes('sql')) { + return { + assistantMessage: [ + '```sql', + 'SELECT * FROM table_name LIMIT 10;', + '```', + '', + '-- (mock)', + ].join('\n'), + } + } + return { assistantMessage: 'Mock response generated in dev mode.' } +} + +const mockApprove = async (payload: aichat.ApproveRequest): Promise => { + const decision = String(payload.decision || '').toLowerCase() + const record = pending.get(payload.approvalId) + pending.delete(payload.approvalId) + + if (!record) { + return { assistantMessage: 'Approval not found (mock).' } + } + + if (decision === 'reject') { + return { assistantMessage: 'OK, cancelled.' } + } + + const state = await loadMockState() + if (record.kind === 'create_datasource') { + state.datasources.push({ id: newId('ds'), ...record.payload }) + return { assistantMessage: 'Created datasource (mock).', effects: { datasourcesChanged: true } } + } + if (record.kind === 'delete_datasource') { + const id = String(record.payload?.datasourceId ?? '') + state.datasources = state.datasources.filter((item) => item.id !== id) + return { assistantMessage: 'Deleted datasource (mock).', effects: { datasourcesChanged: true } } + } + if (record.kind === 'execute_statement') { + const datasourceId = String(record.payload?.datasourceId ?? '') + const database = String(record.payload?.database ?? '') + const statement = String(record.payload?.statement ?? '') + const result = { + columns: ['id', 'name'], + rows: [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + { id: 3, name: 'Carol' }, + ], + rowCount: 3, + elapsedMs: 12, + hasMore: false, + } + lastResultByConversation.set(String(payload.conversationId || ''), { + datasourceId, + datasourceType: String(record.payload?.datasourceType ?? 'mysql'), + database, + statement, + result, + }) + return { + assistantMessage: [ + '### Execution result (mock)', + '', + '- Rows returned: 3', + '- Columns: 2', + '', + '_Results are shown in the Console results panel._', + ].join('\n'), + effects: { + consoleResult: { + datasourceId, + datasourceType: String(record.payload?.datasourceType ?? 'mysql'), + database, + statement, + result, + }, + } as any, + } + } + if (record.kind === 'create_visualization') { + const convoId = String(record.payload?.conversationId || payload.conversationId || '') + const question = String(record.payload?.question || '') + const stored = convoId ? lastResultByConversation.get(convoId) : null + const result = stored?.result + const rows = Array.isArray(result?.rows) ? result.rows : [] + const columns = Array.isArray(result?.columns) ? result.columns : Object.keys(rows[0] || {}) + + const wantsThree = question.includes('three') || question.includes('3d') || question.includes('3-d') || question.includes('三维') || question.includes('3维') + if (wantsThree) { + const points = rows.map((r: any, i: number) => ({ + x: Number(r?.id ?? i), + y: i, + z: typeof r?.name === 'string' ? r.name.length : 0, + color: '#4f46e5', + size: 0.1, + label: typeof r?.name === 'string' ? r.name : String(i), + })) + + return { + assistantMessage: 'Visualization ready (mock).', + effects: { + navigateTo: '/visualization', + visualization: { + title: '3D scatter (mock)', + renderer: 'three', + spec: { type: 'scatter3d', points, axes: { x: 'id', y: 'index', z: 'name.length' }, background: '#0b1020' }, + datasourceId: String(stored?.datasourceId || ''), + database: String(stored?.database || ''), + statement: String(stored?.statement || ''), + rowCount: Number(result?.rowCount || rows.length), + }, + } as any, + } + } + + const pickNumeric = columns.find((c) => rows.some((r: any) => typeof r?.[c] === 'number')) + const pickCategory = columns.find((c) => rows.some((r: any) => typeof r?.[c] === 'string')) + const x = pickCategory || columns[0] || 'x' + const y = pickNumeric || columns.find((c) => c !== x) || columns[0] || 'y' + + const spec = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { values: rows }, + mark: { type: 'bar' }, + encoding: { + x: { field: x, type: 'nominal', title: x }, + y: { field: y, type: 'quantitative', title: y }, + color: { field: x, type: 'nominal', title: x }, + }, + } + + return { + assistantMessage: 'Visualization ready (mock).', + effects: { + navigateTo: '/visualization', + visualization: { + title: `Bar chart: ${y} by ${x} (mock)`, + renderer: 'vega_lite', + spec, + datasourceId: String(stored?.datasourceId || ''), + database: String(stored?.database || ''), + statement: String(stored?.statement || ''), + rowCount: Number(result?.rowCount || rows.length), + }, + } as any, + } + } + return { assistantMessage: 'OK (mock).' } +} + +const mockTurnStream = async (_payload: aichat.TurnRequest): Promise => ({ + streamId: newId('stream'), +}) + +const mockCancelStream = async (_streamId: string): Promise => true + +export const aiChatApi = { + aiChatTurn: (payload: aichat.TurnRequest) => withMock(() => AiChatTurn(payload), () => mockTurn(payload)), + aiChatTurnStream: (payload: aichat.TurnRequest) => withMock(() => AiChatTurnStream(payload), () => mockTurnStream(payload)), + aiChatCancelStream: (streamId: string) => withMock(() => AiChatCancelStream(streamId), () => mockCancelStream(streamId)), + aiChatApprove: (payload: aichat.ApproveRequest) => withMock(() => AiChatApprove(payload), () => mockApprove(payload)), +} diff --git a/frontend/src/services/api/aiconfig.ts b/frontend/src/services/api/aiconfig.ts new file mode 100644 index 0000000..3847e0c --- /dev/null +++ b/frontend/src/services/api/aiconfig.ts @@ -0,0 +1,146 @@ +import { + AssistMongo, + CreateAIConfig, + DeleteAIConfig, + GetAIConfigAPIKey, + ListAIConfigs, + ListAIProviders, + TestAIConfig, + TestAIConfigPayload, + TestAIConfigPreview, + UpdateAIConfig, +} from '@wailsjs/go/main/App' + +import type { AIConfig, MongoAIRequest, MongoAIResponse, ProviderInfo, TestResult } from '@/types' + +import { cloneJson, newId, withMock } from './core' +import { loadMockState } from './mockState' + +const mockListAIConfigs = async () => { + const state = await loadMockState() + return cloneJson(state.aiConfigs) +} + +const mockCreateAIConfig = async (payload: any) => { + const state = await loadMockState() + const created: AIConfig = { + id: newId('ai'), + status: 'connected', + statusDetail: '', + lastCheckedAt: Math.floor(Date.now() / 1000), + lastLatencyMs: 120, + lastModelInfo: payload.model, + createdAt: Date.now(), + ...payload, + } + state.aiConfigs.push(created) + return cloneJson(created) +} + +const mockUpdateAIConfig = async (id: string, payload: any) => { + const state = await loadMockState() + const index = state.aiConfigs.findIndex((item) => item.id === id) + if (index === -1) throw new Error('AI config not found.') + state.aiConfigs[index] = { ...state.aiConfigs[index], ...payload, id } + return cloneJson(state.aiConfigs[index]) +} + +const mockDeleteAIConfig = async (id: string) => { + const state = await loadMockState() + state.aiConfigs = state.aiConfigs.filter((item) => item.id !== id) + return true +} + +const mockGetAIConfigAPIKey = async (id: string) => { + const state = await loadMockState() + const match = state.aiConfigs.find((item) => item.id === id) + if (!match) throw new Error('AI config not found.') + return match.apiKey +} + +const mockTestAIConfig = async (id: string): Promise => { + const state = await loadMockState() + const match = state.aiConfigs.find((item) => item.id === id) + if (!match) throw new Error('AI config not found.') + const latencyMs = 140 + const modelInfo = match.model || match.lastModelInfo || 'unknown' + + match.status = 'connected' + match.statusDetail = '' + match.lastCheckedAt = Math.floor(Date.now() / 1000) + match.lastLatencyMs = latencyMs + match.lastModelInfo = modelInfo + + return { connected: true, latencyMs, modelInfo } +} + +const mockTestAIConfigPayload = async (payload: any): Promise => ({ + connected: true, + latencyMs: 140, + modelInfo: payload.model || 'unknown', +}) + +const mockTestAIConfigPreview = async (_id: string, payload: any): Promise => ({ + connected: true, + latencyMs: 140, + modelInfo: payload.model || 'unknown', +}) + +const mockAssistMongo = async (payload: MongoAIRequest): Promise => ({ + statement: payload.statement, + explanation: 'Mock response generated in dev mode.', +}) + +const mockProviders: Record = { + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4.1-mini', + models: ['gpt-4.1-mini', 'gpt-4o-mini'], + }, + anthropic: { + name: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + defaultModel: 'claude-3-5-sonnet', + models: ['claude-3-5-sonnet', 'claude-3-5-haiku'], + }, + gemini: { + name: 'Google Gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta', + defaultModel: 'gemini-1.5-flash', + models: ['gemini-1.5-flash', 'gemini-1.5-pro'], + }, + deepseek: { + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + defaultModel: 'deepseek-chat', + models: ['deepseek-chat', 'deepseek-reasoner'], + }, + openrouter: { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + defaultModel: 'openai/gpt-4o-mini', + models: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet'], + }, + custom: { + name: 'Custom', + baseUrl: '', + defaultModel: '', + models: [], + }, +} + +export const aiApi = { + listAIConfigs: () => withMock(() => ListAIConfigs(), mockListAIConfigs), + createAIConfig: (payload: any) => withMock(() => CreateAIConfig(payload), () => mockCreateAIConfig(payload)), + updateAIConfig: (id: string, payload: any) => + withMock(() => UpdateAIConfig(id, payload), () => mockUpdateAIConfig(id, payload)), + deleteAIConfig: (id: string) => withMock(() => DeleteAIConfig(id), () => mockDeleteAIConfig(id)), + getAIConfigAPIKey: (id: string) => withMock(() => GetAIConfigAPIKey(id), () => mockGetAIConfigAPIKey(id)), + listAIProviders: () => withMock(() => ListAIProviders(), async () => mockProviders), + testAIConfig: (id: string) => withMock(() => TestAIConfig(id), () => mockTestAIConfig(id)), + testAIConfigPayload: (payload: any) => withMock(() => TestAIConfigPayload(payload), () => mockTestAIConfigPayload(payload)), + testAIConfigPreview: (id: string, payload: any) => + withMock(() => TestAIConfigPreview(id, payload), () => mockTestAIConfigPreview(id, payload)), + assistMongo: (payload: MongoAIRequest) => withMock(() => AssistMongo(payload), () => mockAssistMongo(payload)), +} diff --git a/frontend/src/services/api/auth.ts b/frontend/src/services/api/auth.ts new file mode 100644 index 0000000..c9cf51d --- /dev/null +++ b/frontend/src/services/api/auth.ts @@ -0,0 +1,115 @@ +import { + CompleteAuthLogin, + CurrentAuth, + EnsureAuthenticated, + ListAuthDevices, + LogoutAuth, + PollAuthLogin, + RemoveAuthDevice, + StartAuthLogin, +} from '@wailsjs/go/main/App' + +import type { AuthDeviceList, AuthLoginPoll, AuthLoginStart, AuthState } from '@/types' + +import { cloneJson, newId, withMock } from './core' + +let mockAuthState: AuthState = { + deviceId: 'device_mock', + session: null, + pendingLogin: null, + trial: { + startedAt: Math.floor(Date.now() / 1000), + expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + }, +} + +const mockEnsureAuthenticated = async (): Promise => cloneJson(mockAuthState) +const mockCurrentAuth = async (): Promise => cloneJson(mockAuthState) + +const mockStartAuthLogin = async (_input: { noBrowser?: boolean }): Promise => { + const sessionId = newId('auth') + const loginUrl = `https://futrixdata.com/app?session_id=${sessionId}` + mockAuthState = { + ...mockAuthState, + pendingLogin: { + sessionId, + codeVerifier: 'mock_verifier', + loginUrl, + }, + } + return cloneJson({ loginUrl, sessionId }) +} + +const mockPollAuthLogin = async (): Promise => cloneJson({ status: 'pending' }) + +const mockCompleteAuthLogin = async (_code: string): Promise => { + mockAuthState = { + ...mockAuthState, + pendingLogin: null, + session: { + accessToken: 'access_mock', + refreshToken: 'refresh_mock', + expiresAt: Date.now() + 15 * 60 * 1000, + user: { + id: 'user_mock', + email: 'user@example.com', + displayName: 'Mock User', + avatarUrl: '', + }, + license: { + plan: 'free', + status: 'active', + expiresAt: 0, + }, + }, + } + return cloneJson(mockAuthState) +} + +const mockLogoutAuth = async (): Promise => { + mockAuthState = { + ...mockAuthState, + session: null, + pendingLogin: null, + trial: mockAuthState.trial, + } + return cloneJson(mockAuthState) +} + +const mockListAuthDevices = async (): Promise => { + return cloneJson({ + devices: [ + { + deviceId: mockAuthState.deviceId, + deviceName: 'Mock Device', + platform: 'macos', + lastActiveAt: Date.now(), + createdAt: Date.now(), + }, + ], + limit: 1, + plan: mockAuthState.session?.license.plan || 'free', + }) +} + +const mockRemoveAuthDevice = async (_deviceID: string): Promise => { + return cloneJson({ + devices: [], + limit: 1, + plan: mockAuthState.session?.license.plan || 'free', + }) +} + +export const authApi = { + currentAuth: () => withMock(() => CurrentAuth(), mockCurrentAuth), + ensureAuthenticated: () => withMock(() => EnsureAuthenticated(), mockEnsureAuthenticated), + startAuthLogin: (input: { noBrowser?: boolean } = {}) => + withMock(() => StartAuthLogin(input), () => mockStartAuthLogin(input)), + pollAuthLogin: () => withMock(() => PollAuthLogin(), mockPollAuthLogin), + completeAuthLogin: (code: string) => + withMock(() => CompleteAuthLogin(code), () => mockCompleteAuthLogin(code)), + logoutAuth: () => withMock(() => LogoutAuth(), mockLogoutAuth), + listAuthDevices: () => withMock(() => ListAuthDevices(), mockListAuthDevices), + removeAuthDevice: (deviceID: string) => + withMock(() => RemoveAuthDevice(deviceID), () => mockRemoveAuthDevice(deviceID)), +} diff --git a/frontend/src/services/api/console/index.ts b/frontend/src/services/api/console/index.ts new file mode 100644 index 0000000..d1f529b --- /dev/null +++ b/frontend/src/services/api/console/index.ts @@ -0,0 +1,113 @@ +import { call, withMock } from '../core' +import { + mockDescribeEntity, + mockExecuteStatement, + mockExplainStatement, + mockGetRedisCommandDocs, + mockListEntities, + mockListEntitiesPage, + mockScanRedisKeys, +} from './mocks' + +const resolveExportMime = (fileName: string) => { + const lower = String(fileName || '').toLowerCase() + if (lower.endsWith('.csv')) return 'text/csv;charset=utf-8' + return 'application/json;charset=utf-8' +} + +const browserExportResult = async (fileName: string, content: string): Promise => { + const blob = new Blob([content], { type: resolveExportMime(fileName) }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = fileName + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + return fileName +} + +export type DynamoExecuteLimits = { + maxReturnedRows?: number + maxPages?: number + maxEvaluatedItems?: number +} + +export type RedisKeyMetaItem = { + type: string + ttlMs: number + size: number +} + +export const consoleApi = { + listEntities: (id: string, pattern: string, database: string, executionMode = '', forceRefresh = false) => + withMock( + () => call(() => (window as any).go.main.App.ListEntities(id, pattern, database, executionMode, forceRefresh)), + () => mockListEntities(id), + ), + listEntitiesPage: (id: string, pattern: string, database: string, cursor: string, limit: number, executionMode = '', forceRefresh = false) => + withMock( + () => call(() => (window as any).go.main.App.ListEntitiesPage(id, pattern, database, cursor, limit, executionMode, forceRefresh)), + () => mockListEntitiesPage(id, pattern, cursor, limit), + ), + scanRedisKeys: (id: string, pattern: string, cursor: string) => + withMock( + () => call(() => (window as any).go.main.App.ScanRedisKeys(id, pattern, cursor)), + () => mockScanRedisKeys(id, pattern), + ), + getRedisCommandDocs: (id: string) => + withMock(() => call(() => (window as any).go.main.App.GetRedisCommandDocs(id)), mockGetRedisCommandDocs), + getRedisKeyMeta: (id: string, keys: string[]) => + withMock( + () => call(() => (window as any).go.main.App.GetRedisKeyMeta(id, keys)) as Promise>, + async () => ({} as Record), + ), + describeEntity: (id: string, name: string, database: string, executionMode = '') => + withMock( + () => call(() => (window as any).go.main.App.DescribeEntity(id, name, database, executionMode)), + () => mockDescribeEntity(id, name), + ), + executeStatement: ( + id: string, + statement: string, + database: string, + pagingToken: string, + pageSize: number, + executionMode = '', + approved = false, + dynamoLimits: DynamoExecuteLimits = {}, + ) => + withMock( + () => call(() => (window as any).go.main.App.ExecuteStatement( + id, + statement, + database, + pagingToken, + pageSize, + executionMode, + approved, + Number(dynamoLimits.maxReturnedRows || 0), + Number(dynamoLimits.maxPages || 0), + Number(dynamoLimits.maxEvaluatedItems || 0), + )), + () => mockExecuteStatement(statement, { datasourceId: id }), + ), + explainStatement: (id: string, statement: string, analyze: boolean, database: string, executionMode = '') => + withMock( + () => call(() => (window as any).go.main.App.ExplainStatement(id, statement, analyze, database, executionMode)), + () => mockExplainStatement(statement), + ), + listDatabases: (id: string, pattern: string, executionMode = '') => + withMock(() => call(() => (window as any).go.main.App.ListDatabases(id, pattern, executionMode)), async () => []), + d1DeployMigrations: (id: string) => + withMock( + () => call(() => (window as any).go.main.App.D1DeployMigrations(id)), + async () => true, + ), + exportQueryResult: (fileName: string, content: string) => + withMock( + () => call(() => (window as any).go.main.App.ExportQueryResult(fileName, content)), + () => browserExportResult(fileName, content), + ), +} diff --git a/frontend/src/services/api/console/mocks.test.ts b/frontend/src/services/api/console/mocks.test.ts new file mode 100644 index 0000000..7384595 --- /dev/null +++ b/frontend/src/services/api/console/mocks.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../mockState', () => ({ + loadMockState: async () => ({ + datasources: [ + { id: 'ds_redis', type: 'redis' }, + { id: 'ds_postgres', type: 'postgresql' }, + { id: 'ds_dynamo', type: 'dynamodb' }, + ], + entitiesByDatasource: { + ds_redis: ['session:active', 'user:1'], + }, + }), +})) + +import { mockExecuteStatement, mockListEntitiesPage, mockScanRedisKeys } from './mocks' + +describe('console mock redis key scan', () => { + it('matches wildcard patterns without throwing', async () => { + await expect(mockScanRedisKeys('ds_redis', '*session*')).resolves.toEqual({ + keys: ['session:active'], + cursor: '', + done: true, + }) + }) + + it('treats invalid glob patterns as empty matches instead of throwing', async () => { + await expect(mockScanRedisKeys('ds_redis', '[z-a]')).resolves.toEqual({ + keys: [], + cursor: '', + done: true, + }) + await expect(mockListEntitiesPage('ds_redis', '[z-a]', '', 50)).resolves.toEqual({ + items: [], + cursor: '', + done: true, + }) + }) + + it('keeps quoted orders SQL on non-DynamoDB datasources in the generic SQL mock path', async () => { + const result = await mockExecuteStatement('SELECT * FROM "orders"', { datasourceId: 'ds_postgres' }) + + expect(result.detail?.kind).not.toBe('dynamodb-bounded-pagination') + expect(result.hasMore).toBeUndefined() + expect(result.nextToken).toBeUndefined() + expect(result.columns).toContain('created_at') + }) + + it('returns DynamoDB pagination metadata only for DynamoDB datasources', async () => { + const result = await mockExecuteStatement('SELECT * FROM "orders"', { datasourceId: 'ds_dynamo' }) + + expect(result.detail?.kind).toBe('dynamodb-bounded-pagination') + expect(result.hasMore).toBe(true) + expect(result.nextToken).toBe('mock-dynamo-token-2') + }) + + it('uses Redis-style quoted command parsing for command results', async () => { + await expect(mockExecuteStatement(String.raw`GET "fd quoted key"`)).resolves.toMatchObject({ + rows: [{ result: 'value:fd quoted key' }], + }) + }) + + it('keeps HTTP-style GET mock commands out of Redis command parsing', async () => { + await expect(mockExecuteStatement('GET /_search {"query":{"match_all":{}}}')).resolves.toMatchObject({ + rowCount: 2, + rows: [ + { _id: '1', _index: 'futrixdata-demo-1' }, + { _id: '2', _index: 'futrixdata-demo-1' }, + ], + }) + }) +}) diff --git a/frontend/src/services/api/console/mocks.ts b/frontend/src/services/api/console/mocks.ts new file mode 100644 index 0000000..0bacb8a --- /dev/null +++ b/frontend/src/services/api/console/mocks.ts @@ -0,0 +1,586 @@ +import type { DescribeResult, EntityPage, ExplainResult, IndexInfo, QueryResult, RedisCommandDocsResponse, RedisKeyPage } from '@/types' +import defaultRedisDocs from '@/modules/redis/commands.json' +import { parseRedisCommandArgs } from '@/modules/redis/command-args' +import { cloneJson } from '../core' +import { loadMockState } from '../mockState' + +export const mockListEntities = async (id: string): Promise => { + const state = await loadMockState() + const fromState = state.entitiesByDatasource[id] + if (fromState && fromState.length) return cloneJson(fromState) + + const datasource = state.datasources.find((item) => item.id === id) + if (datasource?.type === 'mysql' || datasource?.type === 'postgresql' || datasource?.type === 'd1') { + return cloneJson(['users', 'orders', 'order_items', 'products']) + } + if (datasource?.type === 'dynamodb') return cloneJson(['users', 'orders', 'order_items', 'products']) + if (datasource?.type === 'redis') return cloneJson(['user:1', 'user:2', 'session:active', 'jobs:pending']) + if (datasource?.type === 'elasticsearch') return cloneJson(['futrixdata-demo-1', 'futrixdata-demo-2', 'futrixdata-demo-3']) + if (datasource?.type === 'chromadb') return cloneJson(['futrix_docs', 'support_vectors']) + return [] +} + +const escapeRegexLiteral = (value: string) => value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&') + +const globToRegexSource = (pattern: string) => { + let source = '' + for (let index = 0; index < pattern.length; index += 1) { + const char = pattern[index] + if (char === '*') { + source += '.*' + continue + } + if (char === '?') { + source += '.' + continue + } + if (char === '[') { + let end = index + 1 + if (pattern[end] === '!') end += 1 + if (pattern[end] === ']') end += 1 + while (end < pattern.length && pattern[end] !== ']') end += 1 + if (end < pattern.length) { + const content = pattern.slice(index + 1, end) + const normalizedContent = content + .replace(/\\/g, '\\\\') + .replace(/^!/, '^') + source += `[${normalizedContent}]` + index = end + continue + } + } + source += escapeRegexLiteral(char) + } + return source +} + +const buildGlobMatcher = (pattern: string) => { + try { + return new RegExp(`^${globToRegexSource(pattern)}$`) + } catch { + return null + } +} + +const mockPagedEntitySource = async (id: string): Promise => { + const state = await loadMockState() + const fromState = state.entitiesByDatasource[id] + if (fromState && fromState.length) return cloneJson(fromState) + + const datasource = state.datasources.find((item) => item.id === id) + if (datasource?.type === 'mysql' || datasource?.type === 'd1') { + return Array.from({ length: 600 }, (_, idx) => `table_${String(idx + 1).padStart(4, '0')}`) + } + if (datasource?.type === 'postgresql') { + const tableNames = Array.from({ length: 300 }, (_, idx) => `table_${String(idx + 1).padStart(4, '0')}`) + return [...tableNames.map((name) => `audit.${name}`), ...tableNames.map((name) => `public.${name}`)] + } + if (datasource?.type === 'dynamodb') { + return Array.from({ length: 350 }, (_, idx) => `ddb_table_${String(idx + 1).padStart(4, '0')}`) + } + return mockListEntities(id) +} + +export const mockListEntitiesPage = async (id: string, pattern: string, cursor: string, limit: number): Promise => { + if (import.meta.env.MODE === 'test') { + return { items: [], cursor: '', done: true } + } + const all = await mockPagedEntitySource(id) + const needle = String(pattern || '').trim().toLowerCase() + let entities = all + if (needle && needle !== '*') { + if (/[*?[\]]/.test(needle)) { + const matcher = buildGlobMatcher(needle) + if (!matcher) return { items: [], cursor: '', done: true } + entities = all.filter((item) => matcher.test(String(item || '').toLowerCase())) + } else { + entities = all.filter((item) => String(item || '').toLowerCase().includes(needle)) + } + } + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(500, Math.floor(limit))) : 100 + const trimmedCursor = String(cursor || '').trim() + + let startIndex = 0 + if (trimmedCursor) { + const idx = entities.indexOf(trimmedCursor) + if (idx >= 0) startIndex = idx + 1 + } + + const items = entities.slice(startIndex, startIndex + safeLimit) + const hasMore = startIndex + safeLimit < entities.length + const nextCursor = hasMore ? items[items.length - 1] || '' : '' + + return { items, cursor: nextCursor, done: !hasMore } +} + +export const mockGetRedisCommandDocs = async (): Promise => { + if (defaultRedisDocs && typeof defaultRedisDocs === 'object') { + const payload = defaultRedisDocs as RedisCommandDocsResponse + if (payload.commands) return payload + } + return { updatedAt: 0, commands: {} } +} + +export const mockScanRedisKeys = async (id: string, pattern: string): Promise => { + const keys = await mockListEntities(id) + const trimmed = String(pattern || '').trim() + if (trimmed !== '' && trimmed !== '*') { + const matcher = buildGlobMatcher(trimmed) + if (!matcher) return { keys: [], cursor: '', done: true } + return { keys: keys.filter((key) => matcher.test(key)), cursor: '', done: true } + } + return { keys, cursor: '', done: true } +} + +const mockMongoDocs = Array.from({ length: 120 }, (_, idx) => ({ + _id: `mock_${idx + 1}`, + name: `user_${idx + 1}`, + status: idx % 2 === 0 ? 'active' : 'inactive', + score: Number(((idx % 40) * 1.7).toFixed(1)), + tags: idx % 3 === 0 ? ['new', 'beta'] : ['default'], + meta: { region: idx % 2 === 0 ? 'us-east' : 'ap-south', tier: idx % 4 === 0 ? 'pro' : 'standard' }, +})) + +const mockSqlRows = Array.from({ length: 160 }, (_, idx) => { + const id = idx + 1 + const pad = String((idx % 60) + 1).padStart(2, '0') + const longText = `row_${id} ` + 'x'.repeat(140) + return { + id, name: `row_${id}`, status: idx % 2 === 0 ? 'active' : 'inactive', created_at: `2026-01-18T17:${pad}:00Z`, + note: longText, col_a: `a_${id}`, col_b: `b_${id}`, col_c: `c_${id}`, col_d: `d_${id}`, col_e: `e_${id}`, + col_f: `f_${id}`, col_g: `g_${id}`, col_h: `h_${id}`, col_i: `i_${id}`, + } +}) + +const mockSqlJoinRows = Array.from({ length: 6 }, (_, idx) => { + const userId = idx + 1 + const orderId = 1000 + idx + 1 + const pad = String(idx + 1).padStart(2, '0') + return { + id: userId, + id__2: orderId, + email: `user_${userId}@example.com`, + total: Number((49.5 + idx * 10).toFixed(2)), + status: idx % 2 === 0 ? 'active' : 'inactive', + status__2: idx % 2 === 0 ? 'paid' : 'pending', + created_at: `2026-02-${pad}T08:00:00Z`, + created_at__2: `2026-03-${pad}T09:15:00Z`, + } +}) + +const parseSqlLimitOffset = (statement: string) => { + const lower = (statement || '').toLowerCase() + const limitMatch = lower.match(/\blimit\s+(\d+)\b/) + const offsetMatch = lower.match(/\boffset\s+(\d+)\b/) + const limit = limitMatch ? Number.parseInt(limitMatch[1] || '', 10) : null + const offset = offsetMatch ? Number.parseInt(offsetMatch[1] || '', 10) : 0 + return { limit: Number.isFinite(limit) ? Math.max(0, limit as number) : null, offset: Number.isFinite(offset) ? Math.max(0, offset) : 0 } +} + +const parseMongoLimit = (statement: string) => { + const limitMatch = statement.match(/\blimit\s*[:(]\s*(\d+)/i) + const skipMatch = statement.match(/\bskip\s*[:(]\s*(\d+)/i) + return { limit: limitMatch ? Number(limitMatch[1]) : 50, skip: skipMatch ? Number(skipMatch[1]) : 0 } +} + +type MockExecuteStatementOptions = { + datasourceId?: string +} + +const mockDatasourceType = async (datasourceId = '') => { + const id = String(datasourceId || '').trim() + if (!id) return '' + const state = await loadMockState() + const datasource = state.datasources.find((item) => item.id === id) + return String(datasource?.type || '').toLowerCase() +} + +const redisMockCommandVerbs = new Set(['get', 'set', 'del', 'type', 'ttl', 'hgetall', 'lrange', 'smembers']) + +export const mockExecuteStatement = async ( + statement: string, + options: MockExecuteStatementOptions = {}, +): Promise => { + const trimmed = (statement || '').trim() + if (trimmed.startsWith('db.')) { + const { limit, skip } = parseMongoLimit(trimmed) + const rows = mockMongoDocs.slice(skip, skip + limit) + return { columns: [], rows, rowCount: rows.length, elapsedMs: 12 } + } + + const lower = trimmed.toLowerCase() + const roughParts = trimmed.split(/\s+/).filter(Boolean) + const roughVerb = (roughParts[0] || '').toLowerCase() + const roughFirstArg = roughParts[1] || '' + const roughIsHttpStyle = + ['get', 'post', 'put', 'delete', 'head', 'patch'].includes(roughVerb) && roughFirstArg.startsWith('/') + const parts = redisMockCommandVerbs.has(roughVerb) && !roughIsHttpStyle + ? parseRedisCommandArgs(trimmed) + : roughParts + const verb = (parts[0] || '').toLowerCase() + const firstArg = parts[1] || '' + const isHttpStyle = + roughIsHttpStyle || (['get', 'post', 'put', 'delete', 'head', 'patch'].includes(verb) && firstArg.startsWith('/')) + + if (verb === 'get' && !isHttpStyle) { + const key = firstArg || '' + const value = key ? `value:${key}` : '(nil)' + return { columns: ['result'], rows: [{ result: value }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'set') { + return { columns: ['result'], rows: [{ result: 'OK' }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'del') { + const count = Math.max(0, parts.length - 1) + return { columns: ['result'], rows: [{ result: `(integer) ${count}` }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'type') { + const key = firstArg || '' + const kind = key.startsWith('user:') + ? 'hash' + : key.includes('jobs:') + ? 'list' + : key.includes('session:') + ? 'set' + : 'string' + return { columns: ['result'], rows: [{ result: kind }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'ttl') { + return { columns: ['result'], rows: [{ result: '(integer) 3600' }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'hgetall') { + const output = ['1) \"name\"', '2) \"Alice\"', '3) \"status\"', '4) \"active\"', '5) \"plan\"', '6) \"pro\"'].join('\n') + return { columns: ['result'], rows: [{ result: output }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'lrange') { + const output = ['1) \"job:1\"', '2) \"job:2\"', '3) \"job:3\"'].join('\n') + return { columns: ['result'], rows: [{ result: output }], rowCount: 1, elapsedMs: 12 } + } + + if (verb === 'smembers') { + const output = ['1) \"sess:1\"', '2) \"sess:2\"', '3) \"sess:3\"'].join('\n') + return { columns: ['result'], rows: [{ result: output }], rowCount: 1, elapsedMs: 12 } + } + + if (lower.startsWith('show create table')) { + const rawTarget = trimmed.replace(/;+\s*$/, '').split(/\s+/).slice(3).join(' ') + const cleaned = rawTarget.replaceAll('`', '').replaceAll('"', '').replaceAll("'", '').trim() + const table = cleaned.split('.').at(-1) || 'table' + return { + columns: ['Table', 'Create Table'], + rows: [ + { + Table: table, + 'Create Table': `CREATE TABLE \`${table}\` (\n \`id\` BIGINT NOT NULL,\n \`created_at\` TIMESTAMP NOT NULL,\n PRIMARY KEY (\`id\`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + }, + ], + rowCount: 1, + elapsedMs: 12, + } + } + if (lower.includes('from "orders"') && await mockDatasourceType(options.datasourceId) === 'dynamodb') { + return { + columns: ['id', 'status'], + rows: [ + { id: 1, status: 'pending' }, + { id: 2, status: 'pending' }, + ], + rowCount: 2, + hasMore: true, + nextToken: 'mock-dynamo-token-2', + prevToken: '', + elapsedMs: 12, + detail: { + kind: 'dynamodb-bounded-pagination', + pageSize: 100, + requestedPageSize: 100, + effectivePageSize: 100, + maxReturnedRows: 100, + maxPages: 20, + maxEvaluatedItems: 5000, + requestedLimits: { + pageSize: 100, + maxReturnedRows: 100, + maxPages: 50, + maxEvaluatedItems: 10000, + }, + effectiveLimits: { + pageSize: 100, + maxReturnedRows: 100, + maxPages: 20, + maxEvaluatedItems: 5000, + }, + pagesFetched: 1, + rowsReturned: 2, + hasMore: true, + nextToken: 'mock-dynamo-token-2', + nextTokenState: 'present', + stopReason: 'page_limit', + clampedLimits: { + maxPages: true, + maxEvaluatedItems: true, + }, + }, + } + } + if (lower.startsWith('select') || lower.startsWith('with') || lower.startsWith('show') || lower.startsWith('describe')) { + if (lower.includes(' join ') && lower.includes('users') && lower.includes('orders')) { + const rows = mockSqlJoinRows + const columns = ['id', 'id__2', 'email', 'total', 'status', 'status__2', 'created_at', 'created_at__2'] + return { + columns, + columnMeta: [ + { key: 'id', name: 'id', origin: { entity: 'users', field: 'id' } }, + { key: 'id__2', name: 'id', origin: { entity: 'orders', field: 'id' } }, + { key: 'email', name: 'email', origin: { entity: 'users', field: 'email' } }, + { key: 'total', name: 'total', origin: { entity: 'orders', field: 'total' } }, + { key: 'status', name: 'status', origin: { entity: 'users', field: 'status' } }, + { key: 'status__2', name: 'status', origin: { entity: 'orders', field: 'status' } }, + { key: 'created_at', name: 'created_at', origin: { entity: 'users', field: 'created_at' } }, + { key: 'created_at__2', name: 'created_at', origin: { entity: 'orders', field: 'created_at' } }, + ], + rowValues: rows.map((row) => columns.map((column) => row[column as keyof typeof row])), + rows, + rowCount: rows.length, + elapsedMs: 12, + } + } + if (lower.includes('count(')) return { columns: ['count'], rows: [{ count: mockSqlRows.length }], rowCount: 1, elapsedMs: 12 } + const { limit, offset } = parseSqlLimitOffset(trimmed) + const pageSize = limit ?? 50 + const rows = mockSqlRows.slice(offset, offset + pageSize) + const columns = rows.length ? Object.keys(rows[0] || {}) : [] + return { columns, rows, rowCount: rows.length, elapsedMs: 12 } + } + + if (lower.startsWith('insert') || lower.startsWith('update') || lower.startsWith('delete')) { + return { columns: [], rows: [], rowCount: 1, elapsedMs: 12 } + } + + if (isHttpStyle) { + if (lower.includes('/collections/') && (lower.includes('/query') || lower.includes('/get'))) { + const rows = [ + { + ids: [['doc-1']], + documents: [['FutrixData ChromaDB integration note']], + metadatas: [[{ source: 'mock', kind: 'note' }]], + distances: [[0.12]], + }, + ] + return { columns: [], rows, rowCount: rows.length, elapsedMs: 12 } + } + if (lower.includes('/_cat/indices')) { + if (lower.includes('format=json')) { + const rows = [ + { index: 'futrixdata-demo-1', health: 'green', status: 'open', 'store.size': '12mb' }, + { index: 'futrixdata-demo-2', health: 'yellow', status: 'open', 'store.size': '48mb' }, + { index: 'futrixdata-demo-3', health: 'red', status: 'open', 'store.size': '1.2gb' }, + ] + return { columns: [], rows, rowCount: rows.length, elapsedMs: 12 } + } + return { columns: [], rows: [{ result: 'index docs.count\nfutrixdata-demo-1 100000\nfutrixdata-demo-2 100000\n' }], rowCount: 1, elapsedMs: 12 } + } + if (lower.includes('_search')) { + const hits = [ + { _id: '1', _index: 'futrixdata-demo-1', _source: { title: 'Mock doc A', score: 1.0 } }, + { _id: '2', _index: 'futrixdata-demo-1', _source: { title: 'Mock doc B', score: 0.9 } }, + ] + return { columns: [], rows: hits, rowCount: hits.length, elapsedMs: 12 } + } + return { columns: [], rows: [{ ok: true }], rowCount: 1, elapsedMs: 12 } + } + + return { columns: [], rows: [], rowCount: 0, elapsedMs: 12 } +} + +export const mockExplainStatement = async (statement: string): Promise => { + const lower = (statement || '').toLowerCase() + const isSQL = lower.startsWith('select') || lower.startsWith('with') || lower.startsWith('update') || lower.startsWith('delete') + const noIndex = lower.includes('noindex') || lower.includes('fullscan') + + if (isSQL) { + if (noIndex) { + return { + usesIndex: false, + stages: ['FULL TABLE SCAN'], + detail: [{ id: 1, select_type: 'SIMPLE', table: 'mock_table', type: 'ALL', possible_keys: 'PRIMARY', key: null, rows: 820, Extra: '' }], + } + } + return { + usesIndex: true, + indexes: ['PRIMARY'], + stages: ['INDEX LOOKUP'], + detail: [{ id: 1, select_type: 'SIMPLE', table: 'mock_table', type: 'ref', possible_keys: 'PRIMARY', key: 'PRIMARY', rows: 52, Extra: 'Using index condition' }], + } + } + + if (noIndex) { + return { usesIndex: false, stages: ['COLLSCAN', 'FETCH'], totalKeysExamined: 0, totalDocsExamined: 820, detail: { stage: 'COLLSCAN', nReturned: 50, executionTimeMillisEstimate: 12 } } + } + + return { + usesIndex: true, + indexes: ['idx_users_status'], + stages: ['IXSCAN', 'FETCH', 'PROJECTION'], + totalKeysExamined: 84, + totalDocsExamined: 52, + detail: { stage: 'FETCH', nReturned: 50, executionTimeMillisEstimate: 3, inputStage: { stage: 'IXSCAN', keyPattern: { status: 1 }, indexName: 'idx_users_status' } }, + } +} + +export const mockDescribeEntity = async (id: string, name: string): Promise => { + const state = await loadMockState() + const datasource = state.datasources.find((item) => item.id === id) + + if (datasource?.type === 'elasticsearch') { + const columns = [ + { name: 'created_at', dataType: 'date', nullable: '-' }, + { name: 'title', dataType: 'text', nullable: '-' }, + { name: 'title.keyword', dataType: 'keyword', nullable: '-' }, + { name: 'user.id', dataType: 'keyword', nullable: '-' }, + { name: 'user.meta.region', dataType: 'keyword', nullable: '-' }, + ] + return { + columns: columns as any, + indexes: [], + details: [ + { label: 'Index', value: name }, + { label: 'Health', value: 'green' }, + { label: 'Status', value: 'open' }, + { label: 'Docs', value: 12345 }, + { label: 'Store', value: '12mb' }, + ], + } + } + + if (datasource?.type === 'mongodb') { + const indexMap: Record = { + users: [ + { name: 'idx_users_status', column: 'status', unique: false }, + { name: 'uid_users_email', column: 'email', unique: true }, + { name: 'idx_users_region_tier', column: 'meta.region, meta.tier', unique: false }, + ], + sample: [ + { name: 'idx_sample_score', column: 'score', unique: false }, + { name: 'idx_sample_status', column: 'status', unique: false }, + ], + } + return { + columns: [], + indexes: indexMap[name] || [{ name: `idx_${name}_created`, column: 'createdAt', unique: false }, { name: `uid_${name}_slug`, column: 'slug', unique: true }], + } + } + + if (datasource?.type === 'chromadb') { + return { + columns: [], + indexes: [], + details: [ + { label: 'Collection', value: name }, + { label: 'ID', value: `mock-${name}` }, + { label: 'Dimension', value: 3 }, + { label: 'Records', value: 2 }, + { label: 'Metadata', value: { source: 'mock' } }, + ], + preview: { + ids: ['doc-1'], + documents: ['FutrixData ChromaDB integration note'], + metadatas: [{ source: 'mock', kind: 'note' }], + }, + } + } + + if (datasource?.type === 'mysql' || datasource?.type === 'postgresql' || datasource?.type === 'd1') { + const columnMap: Record = { + users: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'email', dataType: 'varchar(255)', nullable: 'NO' }, + { name: 'status', dataType: 'varchar(32)', nullable: 'NO' }, + { name: 'created_at', dataType: 'timestamp', nullable: 'NO' }, + ], + orders: [ + { name: 'id', dataType: 'bigint', nullable: 'NO' }, + { name: 'user_id', dataType: 'bigint', nullable: 'NO' }, + { name: 'status', dataType: 'varchar(32)', nullable: 'NO' }, + { name: 'total', dataType: 'numeric(10,2)', nullable: 'NO' }, + { name: 'created_at', dataType: 'timestamp', nullable: 'NO' }, + ], + } + const indexMap: Record = { + users: [ + { name: 'PRIMARY', column: 'id', unique: true }, + { name: 'uid_users_email', column: 'email', unique: true }, + { name: 'idx_users_status', column: 'status', unique: false }, + ], + orders: [ + { name: 'PRIMARY', column: 'id', unique: true }, + { name: 'idx_orders_user_id', column: 'user_id', unique: false }, + { name: 'idx_orders_status', column: 'status', unique: false }, + ], + } + return { + columns: columnMap[name] || [{ name: 'id', dataType: 'bigint', nullable: 'NO' }, { name: 'created_at', dataType: 'timestamp', nullable: 'NO' }], + indexes: indexMap[name] || [{ name: `idx_${name}_id`, column: 'id', unique: false }], + } + } + + if (datasource?.type === 'redis') { + const key = String(name || '') + const kind = key.startsWith('user:') + ? 'hash' + : key.includes('jobs:') + ? 'list' + : key.includes('session:') + ? 'set' + : 'string' + + const previewBase = { kind, limit: 20, truncated: false } + let preview: any = previewBase + + if (kind === 'string') { + preview = { ...previewBase, value: `preview:${key} ` + 'x'.repeat(48), truncated: true } + } else if (kind === 'hash') { + preview = { + ...previewBase, + items: [ + { field: 'name', value: 'Alice' }, + { field: 'status', value: 'active' }, + { field: 'plan', value: 'pro' }, + ], + } + } else if (kind === 'list') { + preview = { + ...previewBase, + items: [ + { index: 0, value: 'job:1' }, + { index: 1, value: 'job:2' }, + { index: 2, value: 'job:3' }, + ], + } + } else if (kind === 'set') { + preview = { + ...previewBase, + items: [{ value: 'sess:1' }, { value: 'sess:2' }, { value: 'sess:3' }], + } + } + + return { + columns: [], + indexes: [], + details: [ + { label: 'Type', value: kind }, + { label: 'TTL', value: 3600 }, + { label: 'Size', value: kind === 'string' ? 128 : 3 }, + ], + preview, + } + } + + return { columns: [], indexes: [] } +} diff --git a/frontend/src/services/api/core.ts b/frontend/src/services/api/core.ts new file mode 100644 index 0000000..78df96e --- /dev/null +++ b/frontend/src/services/api/core.ts @@ -0,0 +1,39 @@ +export const normalizeError = (err: unknown): string => { + if (err instanceof Error) return err.message + if (typeof err === 'string') return err + if (err && typeof err === 'object' && 'message' in err) { + return String((err as { message?: unknown }).message ?? 'Request failed') + } + return 'Request failed' +} + +export const hasWailsBindings = () => { + if (typeof window === 'undefined') return false + const root = (window as { go?: { main?: { App?: unknown } } }).go?.main?.App + return Boolean(root) +} + +export const shouldUseMock = () => import.meta.env.DEV && !hasWailsBindings() + +export const call = async (fn: () => Promise): Promise => { + if (!hasWailsBindings()) { + throw new Error('Wails runtime is not available. Run via Wails to use backend actions.') + } + try { + return await fn() + } catch (err) { + throw new Error(normalizeError(err)) + } +} + +export const withMock = async (fn: () => Promise, mockFn: () => Promise): Promise => { + if (shouldUseMock()) { + return mockFn() + } + return call(fn) +} + +export const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +export const newId = (prefix: string) => + `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` diff --git a/frontend/src/services/api/datasources.ts b/frontend/src/services/api/datasources.ts new file mode 100644 index 0000000..7869895 --- /dev/null +++ b/frontend/src/services/api/datasources.ts @@ -0,0 +1,416 @@ +import { + CreateDatasource, + DeleteDatasource, + GetDatasource, + ListDatasources, + ListSecretProviders, + TestDatasource, + UpdateDatasource, +} from '@wailsjs/go/main/App' + +import type { DataSource, DatasourceMetrics, SecretProviderSummary } from '@/types' +import { tApp } from '@/modules/i18n/appI18n' + +import { call, cloneJson, newId, withMock } from './core' +import { loadMockState } from './mockState' + +const mockListDatasources = async () => { + const state = await loadMockState() + return cloneJson(state.datasources) +} + +const mockGetDatasource = async (id: string) => { + const state = await loadMockState() + const match = state.datasources.find((item) => item.id === id) + if (!match) { + throw new Error('Datasource not found.') + } + return cloneJson(match) +} + +const mockCreateDatasource = async (payload: any) => { + const state = await loadMockState() + const created: DataSource = { id: newId('ds'), ...payload } + state.datasources.push(created) + return cloneJson(created) +} + +const mockUpdateDatasource = async (id: string, payload: any) => { + const state = await loadMockState() + const index = state.datasources.findIndex((item) => item.id === id) + if (index === -1) throw new Error('Datasource not found.') + state.datasources[index] = { ...state.datasources[index], ...payload, id } + return cloneJson(state.datasources[index]) +} + +const mockDeleteDatasource = async (id: string) => { + const state = await loadMockState() + state.datasources = state.datasources.filter((item) => item.id !== id) + return true +} + +const mockSetDatasourceTrustLevel = async (id: string, trustLevel: string) => { + const state = await loadMockState() + const index = state.datasources.findIndex((item) => item.id === id) + if (index === -1) throw new Error('Datasource not found.') + const current = state.datasources[index] + const options = { ...(current.options || {}), trustLevel } + state.datasources[index] = { ...current, options } + return cloneJson(state.datasources[index]) +} + +const mockShouldFailHost = (host: unknown) => /bad|fail|invalid/i.test(String(host || '')) +const mockTestDatasource = async (id: string) => { + const state = await loadMockState() + const ds = state.datasources.find((item) => item.id === id) + if (ds && mockShouldFailHost(ds.host)) { + throw new Error(`Failed to connect to ${ds.host}`) + } + return true +} +const mockTestDatasourcePayload = async (payload: any) => { + const host = payload?.host + if (mockShouldFailHost(host)) { + throw new Error(`Failed to connect to ${host}`) + } + return true +} + +let mockD1Databases: Array<{ id: string; name: string }> = [ + { id: 'db_analytics', name: 'analytics' }, + { id: 'db_orders', name: 'orders' }, +] + +const mockD1OAuthLogin = async () => ({ + accounts: [ + { id: 'acc_mock', name: 'Mock Account' }, + { id: 'acc_mock_alt', name: 'Mock Account Alt' }, + ], + accountId: 'acc_mock', + token: 'token_mock', +}) + +const mockD1OAuthReLogin = async () => mockD1OAuthLogin() +const mockD1IsWranglerInstalled = async () => true + +const mockD1ListCloudDatabases = async (_accountId: string, _token: string) => { + return cloneJson(mockD1Databases) +} + +const mockD1CreateCloudDatabase = async (_accountId: string, _token: string, name: string) => { + const trimmed = String(name || '').trim() + if (!trimmed) throw new Error(tApp('validation.d1CreateDatabaseNameRequired')) + const existing = mockD1Databases.find((item) => item.name.toLowerCase() === trimmed.toLowerCase()) + if (existing) return cloneJson(existing) + const created = { id: newId('db'), name: trimmed } + mockD1Databases = [...mockD1Databases, created] + return cloneJson(created) +} + +const mockDynamoDBSSOListProfiles = async (_configPath = '') => { + return cloneJson([ + { + name: 'default', + region: 'us-east-1', + ssoRegion: 'us-east-1', + startUrl: 'https://example.awsapps.com/start', + accountId: '111111111111', + roleName: 'Admin', + }, + { + name: 'dev', + region: 'us-west-2', + ssoRegion: 'us-west-2', + startUrl: 'https://example.awsapps.com/start', + accountId: '222222222222', + roleName: 'ReadOnly', + }, + ]) +} + +const mockDynamoDBSSOLogin = async (_profile: string) => { + return cloneJson({ + accessToken: 'mock_sso_access_token', + expiresAt: '2099-01-01T00:00:00Z', + }) +} + +const mockDynamoDBSSOListAccounts = async (_accessToken: string, _region: string) => { + return cloneJson([ + { accountId: '111111111111', accountName: 'Mock Prod', emailAddress: 'prod@example.com' }, + { accountId: '222222222222', accountName: 'Mock Dev', emailAddress: 'dev@example.com' }, + ]) +} + +const mockDynamoDBSSOListAccountRoles = async (accountId: string, _accessToken: string, _region: string) => { + const normalizedAccountID = String(accountId || '').trim() || '111111111111' + return cloneJson([ + { roleName: 'Admin', accountId: normalizedAccountID }, + { roleName: 'ReadOnly', accountId: normalizedAccountID }, + ]) +} + +const mockDynamoDBSSOGetRoleCredentials = async ( + _accountId: string, + _roleName: string, + _accessToken: string, + _region: string, +) => { + return cloneJson({ + accessKeyId: 'AKIA_MOCK', + secretAccessKey: 'SECRET_MOCK', + sessionToken: 'SESSION_MOCK', + expiration: 4102444800000, + }) +} + +const mockDynamoDBSSOOAuthAuthorize = async ( + profile: string, + region: string, + _configPath = '', +) => { + const normalizedProfile = String(profile || '').trim() || 'default' + const normalizedRegion = String(region || '').trim() || 'us-east-1' + return cloneJson({ + profile: normalizedProfile, + region: normalizedRegion, + accountId: normalizedProfile === 'dev' ? '222222222222' : '111111111111', + roleName: normalizedProfile === 'dev' ? 'ReadOnly' : 'Admin', + accessKeyId: 'AKIA_MOCK', + secretAccessKey: 'SECRET_MOCK', + sessionToken: 'SESSION_MOCK', + expiration: 4102444800000, + }) +} + +const normalizeRedisNode = (value: unknown) => { + const text = String(value || '').trim() + if (!text) return '' + const at = text.indexOf('@') + return at >= 0 ? text.slice(0, at).trim() : text +} + +const redisNodesFromMockDatasource = (ds: DataSource) => { + const raw = ds.options?.nodes + if (!raw) return [] as string[] + const nodes: string[] = [] + if (Array.isArray(raw)) { + for (const item of raw) { + const node = normalizeRedisNode(item) + if (node) nodes.push(node) + } + } else if (typeof raw === 'string') { + for (const item of raw.split(/[,\s;]+/g)) { + const node = normalizeRedisNode(item) + if (node) nodes.push(node) + } + } + return Array.from(new Set(nodes)).sort() +} + +const mockGetDatasourceMetrics = async (id: string, node = ''): Promise => { + const state = await loadMockState() + const ds = state.datasources.find((item) => item.id === id) + const now = Date.now() + + if (!ds) { + return { + datasourceId: id, + datasourceType: 'unknown', + collectedAt: now, + cpuAvailable: false, + memoryAvailable: false, + warnings: ['datasource not found in mock state'], + } + } + + if (ds.type === 'redis') { + const nodes = redisNodesFromMockDatasource(ds) + const selectedNode = nodes.includes(node) ? node : nodes[0] || '' + if (nodes.length > 1) { + const nodeIndex = Math.max(0, nodes.indexOf(selectedNode)) + const cpuBase = 18 + nodeIndex * 23 + const usedBase = 28 + nodeIndex * 12 + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + node: selectedNode, + nodes, + cpuAvailable: true, + cpuPercent: Math.min(96, cpuBase), + cpuUserSeconds: 11.25 + nodeIndex * 7.3, + cpuSystemSeconds: 4.5 + nodeIndex * 2.1, + memoryAvailable: true, + memoryUsedBytes: usedBase * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: `${usedBase.toFixed(1)} MB`, + memoryTotalText: '128 MB', + } + } + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + cpuAvailable: true, + cpuUserSeconds: 11.25, + cpuSystemSeconds: 4.5, + memoryAvailable: true, + memoryUsedBytes: 32 * 1024 * 1024, + memoryTotalBytes: 128 * 1024 * 1024, + memoryUsedText: '32.0 MB', + memoryTotalText: '128 MB', + } + } + + if (ds.type === 'elasticsearch') { + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + cpuAvailable: true, + cpuPercent: 42.8, + memoryAvailable: true, + memoryUsedBytes: 3_200_000_000, + memoryTotalBytes: 6_400_000_000, + memoryUsedText: '2.98 GB', + memoryTotalText: '5.96 GB', + } + } + + if (ds.type === 'mysql') { + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + cpuAvailable: false, + memoryAvailable: true, + memoryUsedBytes: 512 * 1024 * 1024, + memoryTotalBytes: 1024 * 1024 * 1024, + memoryUsedText: '512 MB', + memoryTotalText: '1.00 GB', + warnings: ['cpu percent requires extra instrumentation'], + } + } + + if (ds.type === 'postgresql') { + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + cpuAvailable: false, + memoryAvailable: true, + memoryUsedBytes: 256 * 1024 * 1024, + memoryTotalBytes: 512 * 1024 * 1024, + memoryUsedText: '256 MB', + memoryTotalText: '512 MB', + warnings: ['cpu percent requires pg_stat_kcache extension'], + } + } + + return { + datasourceId: id, + datasourceType: ds.type, + collectedAt: now, + cpuAvailable: false, + memoryAvailable: false, + warnings: ['metrics not available for this datasource type'], + } +} + +const mockListSecretProviders = async (): Promise => [] + +export const datasourcesApi = { + listDatasources: () => withMock(() => ListDatasources(), mockListDatasources), + listSecretProviders: () => + withMock( + () => ListSecretProviders() as unknown as Promise, + mockListSecretProviders, + ), + getDatasource: (id: string) => withMock(() => GetDatasource(id), () => mockGetDatasource(id)), + createDatasource: (payload: any) => withMock(() => CreateDatasource(payload), () => mockCreateDatasource(payload)), + updateDatasource: (id: string, payload: any) => + withMock(() => UpdateDatasource(id, payload), () => mockUpdateDatasource(id, payload)), + deleteDatasource: (id: string) => withMock(() => DeleteDatasource(id), () => mockDeleteDatasource(id)), + setDatasourceTrustLevel: (id: string, trustLevel: string) => + withMock( + () => call(() => (window as any).go.main.App.SetDatasourceTrustLevel(id, trustLevel)), + () => mockSetDatasourceTrustLevel(id, trustLevel), + ), + testDatasource: (id: string) => withMock(() => TestDatasource(id), () => mockTestDatasource(id)), + getDatasourceMetrics: (id: string, node = '') => + withMock( + () => + call(() => { + const app = (window as any).go?.main?.App + const targetNode = String(node || '').trim() + if (targetNode && typeof app?.GetDatasourceMetricsByNode === 'function') { + return app.GetDatasourceMetricsByNode(id, targetNode) + } + return app.GetDatasourceMetrics(id) + }), + () => mockGetDatasourceMetrics(id, node), + ), + testDatasourcePayload: (payload: any, existingId?: string) => + withMock(() => (window as any).go.main.App.TestDatasourcePayload(payload, existingId || ''), () => mockTestDatasourcePayload(payload)), + d1OAuthLogin: () => + withMock( + () => call(() => (window as any).go.main.App.D1OAuthLogin()), + () => mockD1OAuthLogin(), + ), + d1OAuthReLogin: () => + withMock( + () => call(() => (window as any).go.main.App.D1OAuthReLogin()), + () => mockD1OAuthReLogin(), + ), + d1IsWranglerInstalled: () => + withMock( + () => call(() => (window as any).go.main.App.D1IsWranglerInstalled()), + () => mockD1IsWranglerInstalled(), + ), + d1ListCloudDatabases: (accountId: string, token: string) => + withMock( + () => call(() => (window as any).go.main.App.D1ListCloudDatabases(accountId, token)), + () => mockD1ListCloudDatabases(accountId, token), + ), + d1ListCloudDatabasesForDatasource: (id: string, accountId: string) => + withMock( + () => call(() => (window as any).go.main.App.D1ListCloudDatabasesForDatasource(id, accountId)), + () => mockD1ListCloudDatabases(accountId, ''), + ), + d1CreateCloudDatabase: (accountId: string, token: string, name: string) => + withMock( + () => call(() => (window as any).go.main.App.D1CreateCloudDatabase(accountId, token, name)), + () => mockD1CreateCloudDatabase(accountId, token, name), + ), + dynamoDBSSOListProfiles: (configPath = '') => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOListProfiles(configPath)), + () => mockDynamoDBSSOListProfiles(configPath), + ), + dynamoDBSSOLogin: (profile: string) => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOLogin(profile)), + () => mockDynamoDBSSOLogin(profile), + ), + dynamoDBSSOListAccounts: (accessToken: string, region: string) => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOListAccounts(accessToken, region)), + () => mockDynamoDBSSOListAccounts(accessToken, region), + ), + dynamoDBSSOListAccountRoles: (accountId: string, accessToken: string, region: string) => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOListAccountRoles(accountId, accessToken, region)), + () => mockDynamoDBSSOListAccountRoles(accountId, accessToken, region), + ), + dynamoDBSSOGetRoleCredentials: (accountId: string, roleName: string, accessToken: string, region: string) => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOGetRoleCredentials(accountId, roleName, accessToken, region)), + () => mockDynamoDBSSOGetRoleCredentials(accountId, roleName, accessToken, region), + ), + dynamoDBSSOOAuthAuthorize: (profile: string, region: string, configPath = '') => + withMock( + () => call(() => (window as any).go.main.App.DynamoDBSSOOAuthAuthorize(profile, region, configPath)), + () => mockDynamoDBSSOOAuthAuthorize(profile, region, configPath), + ), +} diff --git a/frontend/src/services/api/embedding.ts b/frontend/src/services/api/embedding.ts new file mode 100644 index 0000000..4c2a274 --- /dev/null +++ b/frontend/src/services/api/embedding.ts @@ -0,0 +1,133 @@ +import { + ComputeEmbeddingForSearch, + CreateEmbeddingConfig, + DeleteEmbeddingConfig, + ListEmbeddingConfigs, + ListEmbeddingProviders, + TestEmbeddingConfig, + TestEmbeddingConfigPayload, + UpdateEmbeddingConfig, +} from '@wailsjs/go/main/App' + +import type { AIConfig, ProviderInfo, TestResult } from '@/types' + +import { cloneJson, newId, withMock } from './core' +import { loadMockState } from './mockState' + +const mockListEmbeddingConfigs = async () => { + const state = await loadMockState() + return cloneJson(state.embeddingConfigs ?? []) +} + +const mockCreateEmbeddingConfig = async (payload: any) => { + const state = await loadMockState() + if (!state.embeddingConfigs) state.embeddingConfigs = [] + const created: AIConfig = { + id: newId('emb'), + status: 'connected', + statusDetail: '', + lastCheckedAt: Math.floor(Date.now() / 1000), + lastLatencyMs: 80, + lastModelInfo: payload.model, + createdAt: Date.now(), + purpose: 'embedding', + ...payload, + } + state.embeddingConfigs.push(created) + return cloneJson(created) +} + +const mockUpdateEmbeddingConfig = async (id: string, payload: any) => { + const state = await loadMockState() + if (!state.embeddingConfigs) state.embeddingConfigs = [] + const index = state.embeddingConfigs.findIndex((item: AIConfig) => item.id === id) + if (index === -1) throw new Error('Embedding config not found.') + state.embeddingConfigs[index] = { ...state.embeddingConfigs[index], ...payload, id } + return cloneJson(state.embeddingConfigs[index]) +} + +const mockDeleteEmbeddingConfig = async (id: string) => { + const state = await loadMockState() + if (!state.embeddingConfigs) state.embeddingConfigs = [] + state.embeddingConfigs = state.embeddingConfigs.filter((item: AIConfig) => item.id !== id) + return true +} + +const mockTestEmbeddingConfig = async (id: string): Promise => { + const state = await loadMockState() + if (!state.embeddingConfigs) state.embeddingConfigs = [] + const match = state.embeddingConfigs.find((item: AIConfig) => item.id === id) + if (!match) throw new Error('Embedding config not found.') + return { connected: true, latencyMs: 80, modelInfo: `${match.model} (384 dims)` } +} + +const mockTestEmbeddingConfigPayload = async (payload: any): Promise => ({ + connected: true, + latencyMs: 80, + modelInfo: `${payload.model || 'unknown'} (384 dims)`, +}) + +const mockComputeEmbeddingForSearch = async (dimensions?: number): Promise => { + const dim = dimensions && dimensions > 0 ? dimensions : 384 + return Array.from({ length: dim }, () => Math.random() * 2 - 1) +} + +const mockEmbeddingProviders: Record = { + openai: { + name: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultModel: 'text-embedding-3-small', + models: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'], + }, + gemini: { + name: 'Google Gemini', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', + defaultModel: 'text-embedding-004', + models: ['text-embedding-004'], + }, + qwen: { + name: 'Alibaba Qwen', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + defaultModel: 'text-embedding-v3', + models: ['text-embedding-v3', 'text-embedding-v2'], + }, + deepseek: { + name: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + defaultModel: 'deepseek-embedding', + models: ['deepseek-embedding'], + }, + openrouter: { + name: 'OpenRouter', + baseUrl: 'https://openrouter.ai/api/v1', + defaultModel: 'openai/text-embedding-3-small', + models: ['openai/text-embedding-3-small', 'openai/text-embedding-3-large'], + }, + custom: { + name: 'Custom', + baseUrl: '', + defaultModel: '', + models: [], + }, +} + +export const embeddingApi = { + listEmbeddingConfigs: () => withMock(() => ListEmbeddingConfigs(), mockListEmbeddingConfigs), + createEmbeddingConfig: (payload: any) => + withMock(() => CreateEmbeddingConfig(payload), () => mockCreateEmbeddingConfig(payload)), + updateEmbeddingConfig: (id: string, payload: any) => + withMock(() => UpdateEmbeddingConfig(id, payload), () => mockUpdateEmbeddingConfig(id, payload)), + deleteEmbeddingConfig: (id: string) => + withMock(() => DeleteEmbeddingConfig(id), () => mockDeleteEmbeddingConfig(id)), + listEmbeddingProviders: () => + withMock(() => ListEmbeddingProviders(), async () => mockEmbeddingProviders), + testEmbeddingConfig: (id: string) => + withMock(() => TestEmbeddingConfig(id), () => mockTestEmbeddingConfig(id)), + testEmbeddingConfigPayload: (payload: any) => + withMock(() => TestEmbeddingConfigPayload(payload), () => mockTestEmbeddingConfigPayload(payload)), + computeEmbeddingForSearch: (embeddingConfigId: string, text: string, dimensions?: number) => + withMock( + () => ComputeEmbeddingForSearch(embeddingConfigId, text, dimensions || 0), + () => mockComputeEmbeddingForSearch(dimensions), + ), +} diff --git a/frontend/src/services/api/history.ts b/frontend/src/services/api/history.ts new file mode 100644 index 0000000..09edc28 --- /dev/null +++ b/frontend/src/services/api/history.ts @@ -0,0 +1,141 @@ +import { AppendHistory, ClearHistory, DeleteHistory, GetHistory, ListAgentAudit, ListHistory } from '@wailsjs/go/main/App' + +import type { AgentAuditEntry, AgentAuditFilter, HistoryEntry, HistoryFilter } from '@/types' + +import { call, cloneJson, shouldUseMock, withMock } from './core' +import { listMockAgentAudit } from './mockAgentAudit' +import { loadMockState } from './mockState' + +const mockHistory: HistoryEntry[] = [] + +const extractElasticsearchTargets = (statement: string): string[] => { + const lines = String(statement || '').split('\n') + const first = lines.find((line) => line.trim())?.trim() || '' + const parts = first.split(/\s+/).filter(Boolean) + if (parts.length < 2) return [] + + let path = String(parts[1] || '').trim() + if (!path) return [] + if (!path.startsWith('/')) path = `/${path}` + + const cleanPath = path.split('?')[0] || '' + const segment = cleanPath.replace(/^\//, '').split('/')[0] || '' + if (!segment || segment.startsWith('_')) return [] + + const targets = segment + .split(',') + .map((value) => value.trim()) + .filter((value) => value && !value.startsWith('_')) + + return Array.from(new Set(targets)) +} + +const listHistory = async (filter: HistoryFilter): Promise => { + if (shouldUseMock()) { + const keyword = (filter.keyword || '').toLowerCase().trim() + return mockHistory + .filter((entry) => { + if (filter.datasourceId && entry.datasourceId !== filter.datasourceId) return false + if (filter.database && entry.database !== filter.database) return false + if (filter.target && !entry.targets.some((target) => target.toLowerCase() === filter.target!.toLowerCase())) return false + if (keyword) { + const hay = `${entry.statement} ${entry.datasourceName} ${entry.datasourceType} ${entry.targets.join(' ')}`.toLowerCase() + if (!hay.includes(keyword)) return false + } + return true + }) + .slice(0, filter.limit || undefined) + } + return call(() => ListHistory(filter)) +} + +const appendHistory = async (payload: { datasourceId: string; statement: string; database?: string }): Promise => { + if (shouldUseMock()) { + const state = await loadMockState() + const datasource = state.datasources.find((item) => item.id === payload.datasourceId) + const datasourceType = datasource?.type || 'unknown' + const datasourceName = datasource?.name || payload.datasourceId + const isElasticsearch = datasourceType === 'elasticsearch' + const database = isElasticsearch ? '' : payload.database || datasource?.database || '' + const targets = isElasticsearch ? extractElasticsearchTargets(payload.statement) : [] + + const now = new Date().toISOString() + const entry: HistoryEntry = { + id: `mock_${Date.now()}`, + statement: payload.statement, + executedAt: now, + datasourceId: payload.datasourceId, + datasourceName, + datasourceType, + database, + targets, + tags: [], + } + mockHistory.unshift(entry) + if (mockHistory.length > 1000) { + mockHistory.length = 1000 + } + return entry + } + return call(() => AppendHistory(payload)) +} + +const getHistory = async (id: string): Promise => { + if (shouldUseMock()) { + const match = mockHistory.find((entry) => entry.id === id) + if (!match) { + throw new Error('History entry not found.') + } + return cloneJson(match) + } + return call(() => GetHistory(id)) +} + +const deleteHistory = async (id: string): Promise => { + if (shouldUseMock()) { + const index = mockHistory.findIndex((entry) => entry.id === id) + if (index === -1) return false + mockHistory.splice(index, 1) + return true + } + return call(() => DeleteHistory(id)) +} + +const clearHistory = async (filter: HistoryFilter): Promise => { + if (shouldUseMock()) { + const matched = await listHistory({ ...filter, limit: undefined }) + if (!matched.length) return 0 + const ids = new Set(matched.map((entry) => entry.id)) + const next = mockHistory.filter((entry) => !ids.has(entry.id)) + const removed = mockHistory.length - next.length + mockHistory.length = 0 + mockHistory.push(...next) + return removed + } + return call(() => ClearHistory(filter)) +} + +export const historyApi = { + listHistory, + appendHistory, + getHistory, + deleteHistory, + clearHistory, + listAgentAudit: (filter: AgentAuditFilter) => + withMock( + () => ListAgentAudit(filter), + async () => { + const mockAgentAudit = listMockAgentAudit() + const keyword = String(filter.keyword || '').toLowerCase().trim() + return cloneJson( + mockAgentAudit.filter((entry) => { + if (filter.accessKey && entry.accessKey !== filter.accessKey) return false + if (filter.protocol && entry.protocol !== filter.protocol) return false + if (!keyword) return true + const hay = `${entry.accessKey} ${entry.agentName} ${entry.agentType || ''} ${entry.protocol} ${entry.toolName} ${entry.summary} ${entry.statement || ''}`.toLowerCase() + return hay.includes(keyword) + }).slice(0, filter.limit || undefined), + ) + }, + ) as Promise, +} diff --git a/frontend/src/services/api/logs.ts b/frontend/src/services/api/logs.ts new file mode 100644 index 0000000..7805a43 --- /dev/null +++ b/frontend/src/services/api/logs.ts @@ -0,0 +1,15 @@ +import { call } from './core' + +export type DiagnosticsSettings = { + datasourceTimingLogEnabled: boolean +} + +export const logsApi = { + exportLogs: () => call(() => (window as any).go.main.App.ExportLogs()), + getDiagnosticsSettings: () => + call(() => (window as any).go.main.App.GetDiagnosticsSettings()), + setDatasourceTimingLogEnabled: (enabled: boolean) => + call(() => (window as any).go.main.App.SetDatasourceTimingLogEnabled(enabled)), + recordClientError: (kind: string, message: string, detail: string) => + call(() => (window as any).go.main.App.RecordClientError(kind, message, detail)), +} diff --git a/frontend/src/services/api/mockAgentAudit.ts b/frontend/src/services/api/mockAgentAudit.ts new file mode 100644 index 0000000..5970ddb --- /dev/null +++ b/frontend/src/services/api/mockAgentAudit.ts @@ -0,0 +1,349 @@ +import type { AgentAuditEntry } from '@/types' + +import { cloneJson } from './core' + +type MockSkillTemplate = { + id: string + name: string + filename: string + suggestedPath: string + content: string + notes?: string +} + +type MockMCPSnippet = { + id: string + label: string + format: string + content: string + suggestedPath: string + configKey: string + notes?: string +} + +type MockManualInstallInfo = { + cliBinaryPath: string + accessKey: string + agentName: string + skillTemplates: MockSkillTemplate[] + mcpSnippets: MockMCPSnippet[] +} + +const manualInstallInfo: MockManualInstallInfo = { + cliBinaryPath: '/usr/local/bin/futrixdata-cli', + accessKey: 'agent_mock_1234', + agentName: 'agent-1234', + skillTemplates: [ + { + id: 'claude', + name: 'Claude Code', + filename: 'SKILL.md', + suggestedPath: '~/.claude/skills/futrixdata/SKILL.md', + content: + '# FutrixData (mock skill content)\n' + + 'Run `futrixdata-cli --agent-access-key agent_mock_1234 tool call execute_statement`.\n', + }, + { + id: 'cursor', + name: 'Cursor', + filename: 'futrixdata.mdc', + suggestedPath: '~/.cursor/rules/futrixdata.mdc', + content: + '# FutrixData (mock cursor rule)\n' + + 'Run `futrixdata-cli --agent-access-key agent_mock_1234 tool call execute_statement`.\n', + }, + { + id: 'codex', + name: 'Codex', + filename: 'SKILL.md', + suggestedPath: '~/.codex/skills/futrixdata/SKILL.md', + content: + '# FutrixData (mock codex skill)\n' + + 'Run `futrixdata-cli --agent-access-key agent_mock_1234 tool call execute_statement`.\n', + }, + { + id: 'opencode', + name: 'OpenCode', + filename: 'futrixdata.md', + suggestedPath: '~/.opencode/skills/futrixdata.md', + content: + '# FutrixData (mock opencode skill)\n' + + 'Run `futrixdata-cli --agent-access-key agent_mock_1234 tool call execute_statement`.\n', + }, + ], + mcpSnippets: [ + { + id: 'standard-json', + label: 'Standard MCP (JSON)', + format: 'json', + content: + '{\n "mcpServers": {\n "futrixdata": {\n "command": "/usr/local/bin/futrixdata-cli",\n "args": ["mcp", "serve", "--agent-access-key", "agent_mock_1234"]\n }\n }\n}\n', + suggestedPath: '~/.claude/settings.json | ~/.cursor/mcp.json | any MCP client', + configKey: 'mcpServers.futrixdata', + notes: 'Works with most MCP-capable clients.', + }, + { + id: 'codex-toml', + label: 'Codex (TOML)', + format: 'toml', + content: + '[mcp_servers.futrixdata]\ncommand = "/usr/local/bin/futrixdata-cli"\nargs = ["mcp", "serve", "--agent-access-key", "agent_mock_1234"]\n', + suggestedPath: '~/.codex/config.toml', + configKey: 'mcp_servers.futrixdata', + }, + { + id: 'opencode-json', + label: 'OpenCode (JSON)', + format: 'json', + content: + '{\n "mcp": {\n "futrixdata": {\n "type": "local",\n "command": ["/usr/local/bin/futrixdata-cli", "mcp", "serve", "--agent-access-key", "agent_mock_1234"]\n }\n }\n}\n', + suggestedPath: '~/.config/opencode/opencode.json', + configKey: 'mcp.futrixdata', + }, + ], +} + +const agentAuditEntries: AgentAuditEntry[] = [ + { + id: 'audit_skill_1', + accessKey: 'agent_mock_1234', + agentName: 'agent-1234', + agentType: 'manual', + protocol: 'skill', + toolName: 'execute_statement', + summary: 'Read users table', + statement: 'SELECT id, email\nFROM users\nORDER BY id DESC\nLIMIT 50', + datasourceId: 'ds_mysql', + datasourceName: 'MySQL', + datasourceType: 'mysql', + target: 'users', + status: 'success', + message: 'Completed in mock mode.', + executedAt: '2026-04-22T15:10:00.000Z', + }, + { + id: 'audit_mcp_1', + accessKey: 'agent_mock_1234', + agentName: 'agent-1234', + agentType: 'manual', + protocol: 'mcp', + toolName: 'list_tables', + summary: 'List tables through MCP', + statement: 'SHOW TABLES', + datasourceId: 'ds_mysql', + datasourceName: 'MySQL', + datasourceType: 'mysql', + target: 'schema', + status: 'success', + message: 'Returned mock catalog.', + executedAt: '2026-04-22T15:12:00.000Z', + }, + { + id: 'audit_skill_2', + accessKey: 'agent_mock_9876', + agentName: 'agent-9876', + agentType: 'detected', + protocol: 'skill', + toolName: 'describe_table', + summary: 'Inspect orders table', + statement: 'DESCRIBE orders', + datasourceId: 'ds_postgres', + datasourceName: 'PostgreSQL', + datasourceType: 'postgresql', + target: 'orders', + status: 'error', + message: 'Mock timeout while inspecting table.', + executedAt: '2026-04-22T15:15:00.000Z', + }, + { + id: 'audit_skill_3', + accessKey: 'agent_mock_1234', + agentName: 'agent-1234', + agentType: 'manual', + protocol: 'skill', + toolName: 'execute_statement', + summary: 'Bulk delete users', + statement: 'DELETE FROM users', + datasourceId: 'ds_mysql', + datasourceName: 'MySQL', + datasourceType: 'mysql', + target: 'users', + status: 'approval_required', + message: 'Awaiting approval before execution.', + riskAttribution: { + source: 'risk_engine', + action: 'require_approval', + level: 'high', + ruleId: 'builtin_delete_full_table', + ruleCode: 'delete_full_table', + ruleDescription: 'DELETE without WHERE clause', + reasons: ['DELETE statement on `users` does not include a WHERE clause'], + }, + executedAt: '2026-04-22T15:20:00.000Z', + }, + { + id: 'audit_mcp_2', + accessKey: 'agent_mock_1234', + agentName: 'agent-1234', + agentType: 'manual', + protocol: 'mcp', + toolName: 'create_datasource', + summary: 'Create datasource "warehouse"', + datasourceName: '', + target: 'warehouse', + status: 'approval_required', + message: 'System policy requires approval before creating a datasource.', + riskAttribution: { + source: 'policy', + action: 'require_approval', + }, + executedAt: '2026-04-22T15:22:00.000Z', + }, +] + +export const getMockManualInstallInfo = () => cloneJson(manualInstallInfo) + +// Mirror the real backend: the snippet/skill template strings embed the +// caller's access key verbatim, so swap the placeholder token used in the +// stock manualInstallInfo (agent_mock_1234) for the requested key. Without +// this the mock would silently return the wrong key in the snippet body and +// our E2E test wouldn't notice — exactly the regression codex flagged. +const rebindAccessKey = (info: MockManualInstallInfo, accessKey: string): MockManualInstallInfo => { + const placeholder = manualInstallInfo.accessKey + const replace = (text: string) => text.split(placeholder).join(accessKey) + return { + ...info, + accessKey, + skillTemplates: info.skillTemplates.map((t) => ({ ...t, content: replace(t.content) })), + mcpSnippets: info.mcpSnippets.map((s) => ({ ...s, content: replace(s.content) })), + } +} + +export const getMockManualInstallInfoForKey = (accessKey: string) => { + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (!identity) { + // Mirror backend error from GetManualInstallInfoForKey in app_skill.go so a + // stale-key regression fails loudly in dev mode instead of silently + // serving fabricated snippets. + throw new Error('agent identity not found') + } + return cloneJson({ + ...rebindAccessKey(manualInstallInfo, identity.accessKey), + agentName: identity.name, + }) +} + +export const createMockManualAgent = (name: string): MockIdentity => { + const trimmed = String(name || '').trim() || 'manual-agent' + const accessKey = `agent_mock_${Math.random().toString(16).slice(2, 10)}` + const now = new Date().toISOString() + const identity: MockIdentity = { + accessKey, + name: trimmed, + agentType: 'manual', + source: 'manual', + createdAt: now, + updatedAt: now, + } + mockIdentities.push(identity) + return cloneJson(identity) +} + +export const listMockAgentAudit = () => cloneJson(agentAuditEntries) + +export const renameMockAgentIdentity = (accessKey: string, name: string) => { + const trimmed = String(name || '').trim() + const nextName = trimmed || 'agent-1234' + if (manualInstallInfo.accessKey === accessKey) { + manualInstallInfo.agentName = nextName + } + agentAuditEntries.forEach((entry) => { + if (entry.accessKey === accessKey) { + entry.agentName = nextName + } + }) + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (identity) { + identity.name = nextName + identity.updatedAt = new Date().toISOString() + } + return cloneJson({ + accessKey, + name: nextName, + agentType: accessKey === manualInstallInfo.accessKey ? 'manual' : 'detected', + }) +} + +type MockIdentity = { + accessKey: string + name: string + agentType: string + source: string + installPath?: string + datasourceScope?: string + allowedDatasourceIds?: string[] + expiresAt?: string + revokedAt?: string + createdAt: string + updatedAt: string + sensitivityClassificationGrant?: boolean + datasourceManagementGrant?: boolean +} + +const mockIdentities: MockIdentity[] = [ + { + accessKey: 'agent_mock_1234', + name: 'agent-1234', + agentType: 'manual', + source: 'manual', + createdAt: '2026-04-22T15:00:00.000Z', + updatedAt: '2026-04-22T15:00:00.000Z', + }, + { + accessKey: 'agent_mock_9876', + name: 'agent-9876', + agentType: 'claude', + source: 'detected', + installPath: '~/.claude/skills/futrixdata/SKILL.md', + createdAt: '2026-04-22T15:00:00.000Z', + updatedAt: '2026-04-22T15:00:00.000Z', + }, +] + +export const revokeMockAgentIdentity = (accessKey: string): MockIdentity => { + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (identity) { + identity.revokedAt = new Date().toISOString() + identity.updatedAt = identity.revokedAt + } + return cloneJson(identity || { accessKey, name: '', agentType: '', source: '', createdAt: '', updatedAt: '' }) +} + +export const unrevokeMockAgentIdentity = (accessKey: string): MockIdentity => { + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (identity) { + identity.revokedAt = '' + identity.updatedAt = new Date().toISOString() + } + return cloneJson(identity || { accessKey, name: '', agentType: '', source: '', createdAt: '', updatedAt: '' }) +} + +export const setMockAgentSensitivityGrant = (accessKey: string, grant: boolean): MockIdentity => { + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (identity) { + identity.sensitivityClassificationGrant = grant + identity.updatedAt = new Date().toISOString() + } + return cloneJson(identity || { accessKey, name: '', agentType: '', source: '', createdAt: '', updatedAt: '' }) +} + +export const setMockAgentDatasourceManagementGrant = (accessKey: string, grant: boolean): MockIdentity => { + const identity = mockIdentities.find((item) => item.accessKey === accessKey) + if (identity) { + identity.datasourceManagementGrant = grant + identity.updatedAt = new Date().toISOString() + } + return cloneJson(identity || { accessKey, name: '', agentType: '', source: '', createdAt: '', updatedAt: '' }) +} + +export const listMockAgentIdentities = () => cloneJson(mockIdentities) diff --git a/frontend/src/services/api/mockState.test.ts b/frontend/src/services/api/mockState.test.ts new file mode 100644 index 0000000..624d4a6 --- /dev/null +++ b/frontend/src/services/api/mockState.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('loadMockState', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() + }) + + it('loads fixture JSON in dev browser mode', async () => { + vi.stubEnv('DEV', 'true') + vi.stubEnv('MODE', 'development') + + const { loadMockState } = await import('./mockState') + const state = await loadMockState() + + expect(state.datasources.some((item) => item.id === 'ds_mysql')).toBe(true) + expect(Array.isArray(state.aiConfigs)).toBe(true) + }) + + it('resolves fixture loaders by normalized suffix when exact key lookup misses', async () => { + const { findRuntimeJsonLoader } = await import('./mockState') + + const direct = async () => ({}) + const nested = async () => ({}) + const loader = findRuntimeJsonLoader( + { + '/@fs/Users/test/repo/data/datasources.json': nested, + '../../../../data/entities.json': direct, + }, + '../../../../data/datasources.json', + ) + + expect(loader).toBe(nested) + }) +}) diff --git a/frontend/src/services/api/mockState.ts b/frontend/src/services/api/mockState.ts new file mode 100644 index 0000000..d764dc7 --- /dev/null +++ b/frontend/src/services/api/mockState.ts @@ -0,0 +1,128 @@ +import type { AIConfig, DataSource } from '@/types' + +import { cloneJson } from './core' + +type MockState = { + datasources: DataSource[] + aiConfigs: AIConfig[] + embeddingConfigs?: AIConfig[] + entitiesByDatasource: Record +} + +let mockState: MockState | null = null + +const runtimeJsonLoaders = import.meta.glob('../../../../data/*.json', { import: 'default' }) as Record< + string, + () => Promise +> + +export const findRuntimeJsonLoader = ( + loaders: Record Promise>, + path: string, +): (() => Promise) | undefined => { + if (loaders[path]) return loaders[path] + const normalizedPath = normalizeFixturePath(path) + for (const [candidate, load] of Object.entries(loaders)) { + if (normalizeFixturePath(candidate).endsWith(normalizedPath)) { + return load + } + } + return undefined +} + +const loadRuntimeJson = async (path: string): Promise => { + const load = findRuntimeJsonLoader(runtimeJsonLoaders, path) + if (!load) { + throw new Error(`Missing mock fixture: ${path}`) + } + return cloneJson((await load()) as T) +} + +const isMissingMockFixtureError = (error: unknown) => + error instanceof Error && error.message.startsWith('Missing mock fixture:') + +const loadRuntimeJsonOr = async (path: string, fallback: () => T): Promise => { + try { + return await loadRuntimeJson(path) + } catch (error) { + if (isMissingMockFixtureError(error)) { + return cloneJson(fallback()) + } + throw error + } +} + +const normalizeFixturePath = (path: string) => { + const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '') + const dataIndex = normalized.lastIndexOf('/data/') + if (dataIndex !== -1) { + return normalized.slice(dataIndex + 1) + } + return normalized.replace(/^(?:\.\.\/)+/, '') +} + +const testMockState = (): MockState => ({ + datasources: [ + { + id: 'ds_mysql', + name: 'MySQL', + type: 'mysql', + host: 'localhost', + port: 3306, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + { + id: 'ds_postgres', + name: 'PostgreSQL', + type: 'postgresql', + host: 'localhost', + port: 5432, + username: '', + password: '', + database: '', + authSource: '', + options: {}, + }, + { + id: 'ds_d1', + name: 'D1', + type: 'd1', + host: '', + port: 0, + username: '', + password: '', + database: '', + authSource: '', + options: { mode: 'local', binding: 'DB', databaseId: 'local-db-id' }, + }, + ], + aiConfigs: [], + entitiesByDatasource: {}, +}) + +export const loadMockState = async () => { + if (mockState) return mockState + if (!import.meta.env.DEV) { + throw new Error('Wails runtime is not available. Run via Wails to use backend actions.') + } + if (import.meta.env.MODE === 'test') { + mockState = cloneJson(testMockState()) + return mockState + } + const defaults = testMockState() + const [datasources, aiConfigs, entitiesByDatasource] = await Promise.all([ + loadRuntimeJsonOr('../../../../data/datasources.json', () => defaults.datasources), + loadRuntimeJsonOr('../../../../data/aiconfigs.json', () => defaults.aiConfigs), + loadRuntimeJsonOr>('../../../../data/entities.json', () => defaults.entitiesByDatasource), + ]) + mockState = { + datasources, + aiConfigs, + entitiesByDatasource, + } + return mockState +} diff --git a/frontend/src/services/api/redisProtobuf.ts b/frontend/src/services/api/redisProtobuf.ts new file mode 100644 index 0000000..8024b46 --- /dev/null +++ b/frontend/src/services/api/redisProtobuf.ts @@ -0,0 +1,35 @@ +import { + DeleteRedisProtobufSchema, + GetRedisProtobufSchema, + ListRedisProtobufSchemas, + SaveRedisProtobufSchema, +} from '@wailsjs/go/main/App' + +import { call } from './core' + +export type RedisProtobufSchema = { + id: string + datasourceId: string + name: string + content: string + createdAt: string + updatedAt: string +} + +export type RedisProtobufSavePayload = { + id?: string + datasourceId: string + name: string + content: string +} + +export const redisProtobufApi = { + listRedisProtobufSchemas: (datasourceId: string): Promise => + call(() => ListRedisProtobufSchemas(datasourceId) as Promise), + getRedisProtobufSchema: (id: string): Promise => + call(() => GetRedisProtobufSchema(id) as Promise), + saveRedisProtobufSchema: (payload: RedisProtobufSavePayload): Promise => + call(() => SaveRedisProtobufSchema(payload) as Promise), + deleteRedisProtobufSchema: (id: string): Promise => + call(() => DeleteRedisProtobufSchema(id) as Promise), +} diff --git a/frontend/src/services/api/schemaPrivacy.ts b/frontend/src/services/api/schemaPrivacy.ts new file mode 100644 index 0000000..a78ddf8 --- /dev/null +++ b/frontend/src/services/api/schemaPrivacy.ts @@ -0,0 +1,81 @@ +import { + SchemaPrivacyListConsents, + SchemaPrivacyGetConsent, + SchemaPrivacySetConsent, + SchemaPrivacyListAudit, +} from '@wailsjs/go/main/App' + +import { call, withMock } from './core' + +export type SchemaConsent = '' | 'allowed' | 'denied' + +export type SchemaConsentSummary = { + datasourceId: string + datasourceName: string + datasourceType: string + consent: SchemaConsent + // RFC3339 string from the Go backend (schemaprivacy.AuditEntry.CreatedAt). + // The frontend used to type this as a number and multiply by 1000, which + // returned NaN for ISO strings — verify before changing back. + lastSentAt?: string + lastStatus?: string +} + +export type SchemaAuditEntry = { + id: string + datasourceId: string + datasourceName: string + datasourceType: string + triggerSource: string + status: 'allowed' | 'denied' + entityCount: number + fieldCount: number + includesComments: boolean + providerType: string + model: string + aiConfigId: string + reason: string + createdAt: string +} + +const unwrap = (value: any, fallback: T): T => { + if (!value || typeof value !== 'object') return fallback + if (value.error) throw new Error(String(value.error)) + return value as T +} + +export const schemaPrivacyApi = { + listConsents: () => + withMock( + async () => unwrap<{ items: SchemaConsentSummary[] }>(await SchemaPrivacyListConsents(), { items: [] }), + async () => ({ items: [] as SchemaConsentSummary[] }), + ), + getConsent: (datasourceId: string) => + withMock( + async () => unwrap(await SchemaPrivacyGetConsent(datasourceId), { + datasourceId, + datasourceName: '', + datasourceType: '', + consent: '' as SchemaConsent, + }), + async () => ({ + datasourceId, + datasourceName: '', + datasourceType: '', + consent: '' as SchemaConsent, + }), + ), + setConsent: (datasourceId: string, consent: SchemaConsent) => + withMock( + async () => unwrap<{ datasourceId: string; consent: SchemaConsent }>( + await call(() => SchemaPrivacySetConsent(datasourceId, consent)), + { datasourceId, consent }, + ), + async () => ({ datasourceId, consent }), + ), + listAudit: (datasourceId = '', limit = 50) => + withMock( + async () => unwrap<{ items: SchemaAuditEntry[] }>(await SchemaPrivacyListAudit(datasourceId, limit), { items: [] }), + async () => ({ items: [] as SchemaAuditEntry[] }), + ), +} diff --git a/frontend/src/services/api/sensitivity.ts b/frontend/src/services/api/sensitivity.ts new file mode 100644 index 0000000..bfdcbc9 --- /dev/null +++ b/frontend/src/services/api/sensitivity.ts @@ -0,0 +1,166 @@ +import { call, cloneJson, withMock } from './core' +import { tApp, tAppEn } from '@/modules/i18n/appI18n' + +const defaultLevelMeta = [ + { id: 1, key: 'L1', color: 'green' }, + { id: 2, key: 'L2', color: 'blue' }, + { id: 3, key: 'L3', color: 'yellow' }, + { id: 4, key: 'L4', color: 'orange' }, + { id: 5, key: 'L5', color: 'red' }, +] + +const levelNameKey = (key: string) => `sensitivity.levelDef.${key}.name` +const levelDescKey = (key: string) => `sensitivity.levelDef.${key}.desc` + +const buildDefaultLevelConfig = () => ({ + levels: defaultLevelMeta.map((level) => ({ + ...level, + name: tAppEn(levelNameKey(level.key)), + nameEn: tAppEn(levelNameKey(level.key)), + description: tAppEn(levelDescKey(level.key)), + descriptionEn: tAppEn(levelDescKey(level.key)), + })), + agentAccessFrom: 1, + agentAccessTo: 3, +}) + +const localizeLevelConfig = (config: ReturnType) => { + const cloned = cloneJson(config) + cloned.levels = cloned.levels.map((level) => { + const nameEn = level.nameEn || tAppEn(levelNameKey(level.key)) + const descriptionEn = level.descriptionEn || tAppEn(levelDescKey(level.key)) + return { + ...level, + nameEn, + descriptionEn, + name: level.name === nameEn ? tApp(levelNameKey(level.key)) : level.name, + description: level.description === descriptionEn ? tApp(levelDescKey(level.key)) : level.description, + } + }) + return cloned +} + +let mockCustomRules = '' +let mockMode = 'whitelist' +let mockLevelConfig = buildDefaultLevelConfig() + +const mockProgress = (datasourceId: string) => ({ + datasourceId, + status: 'completed', + totalEntities: 0, + scannedEntities: 0, +}) + +const parseLevels = (levelsJSON: string) => { + try { + const levels = JSON.parse(levelsJSON) + return Array.isArray(levels) ? levels : [] + } catch { + return [] + } +} + +export const sensitivityApi = { + scan: (datasourceId: string, aiConfigId: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivityScan(datasourceId, aiConfigId)), + async () => ({ status: 'started', datasourceId, aiConfigId }), + ), + + getProgress: (datasourceId: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivityGetProgress(datasourceId)), + async () => mockProgress(datasourceId), + ), + + getReport: (datasourceId: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivityGetReport(datasourceId)), + async () => ({ found: false, datasourceId }), + ), + + confirmField: ( + datasourceId: string, + entityName: string, + fieldName: string, + level: string, + category: string, + ) => + withMock( + () => + call(() => + (window as any).go.main.App.SensitivityConfirmField( + datasourceId, + entityName, + fieldName, + level, + category, + ), + ), + async () => ({ ok: true }), + ), + + getMode: () => + withMock( + () => call(() => (window as any).go.main.App.SensitivityGetMode()), + async () => ({ mode: mockMode }), + ), + + setMode: (mode: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivitySetMode(mode)), + async () => { + mockMode = mode + return { ok: true } + }, + ), + + getCustomRules: () => + withMock( + () => call(() => (window as any).go.main.App.SensitivityGetCustomRules()), + async () => ({ rules: mockCustomRules }), + ), + + setCustomRules: (rules: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivitySetCustomRules(rules)), + async () => { + mockCustomRules = rules + return { ok: true } + }, + ), + + deleteDatasource: (datasourceId: string) => + withMock( + () => call(() => (window as any).go.main.App.SensitivityDeleteDatasource(datasourceId)), + async () => ({ ok: true, datasourceId }), + ), + + getLevelConfig: () => + withMock( + () => call(() => (window as any).go.main.App.SensitivityGetLevelConfig()), + async () => localizeLevelConfig(mockLevelConfig), + ), + + setLevelConfig: (levelsJSON: string, agentAccessFrom: number, agentAccessTo: number) => + withMock( + () => call(() => (window as any).go.main.App.SensitivitySetLevelConfig(levelsJSON, agentAccessFrom, agentAccessTo)), + async () => { + mockLevelConfig = { + levels: parseLevels(levelsJSON), + agentAccessFrom, + agentAccessTo, + } + return { ok: true } + }, + ), + + resetLevelConfig: () => + withMock( + () => call(() => (window as any).go.main.App.SensitivityResetLevelConfig()), + async () => { + mockLevelConfig = buildDefaultLevelConfig() + return { ok: true } + }, + ), +} diff --git a/frontend/src/services/api/skill.ts b/frontend/src/services/api/skill.ts new file mode 100644 index 0000000..8bac5b6 --- /dev/null +++ b/frontend/src/services/api/skill.ts @@ -0,0 +1,223 @@ +import { + DetectAIAgents, + InstallSkill, + UninstallSkill, + MarkSkillInstallPrompted, + SkillInstallPrompted, + DetectMCPAgents, + InstallMCP, + UninstallMCP, + AuthorizeCodexPlugin, + GetManualInstallInfo, + GetManualInstallInfoForKey, + CreateManualAgent, + RenameAgentIdentity, + RevokeAgentIdentity, + UnrevokeAgentIdentity, + SetAgentSensitivityGrant, + SetAgentDatasourceManagementGrant, + ListAgentIdentities, +} from '@wailsjs/go/main/App' + +import { + getMockManualInstallInfo, + getMockManualInstallInfoForKey, + createMockManualAgent, + renameMockAgentIdentity, + revokeMockAgentIdentity, + unrevokeMockAgentIdentity, + setMockAgentSensitivityGrant, + setMockAgentDatasourceManagementGrant, + listMockAgentIdentities, +} from './mockAgentAudit' +import { cloneJson, withMock } from './core' + +export interface SkillAgent { + id: string + name: string + detected: boolean + installed: boolean + installPath: string + accessKey?: string + version?: string + managed?: boolean + needsUpdate?: boolean +} + +export interface MCPAgent { + id: string + name: string + detected: boolean + installed: boolean + configPath: string + accessKey?: string +} + +export interface SkillInstallOutcome { + id: string + name: string + path: string + success: boolean + error?: string + /** Per-install identity key — present on successful outcomes so the + * install dialog can apply a sensitivity-classification grant. */ + accessKey?: string +} + +export interface SkillInstallResult { + installed: SkillInstallOutcome[] +} + +export interface SkillTemplate { + id: string + name: string + filename: string + suggestedPath: string + content: string + notes?: string +} + +export interface MCPSnippet { + id: string + label: string + format: string + content: string + suggestedPath: string + configKey: string + notes?: string +} + +export interface ManualInstallInfo { + cliBinaryPath: string + accessKey: string + agentName: string + skillTemplates: SkillTemplate[] + mcpSnippets: MCPSnippet[] +} + +export interface AgentIdentity { + accessKey: string + name: string + agentType: string + source: string + installPath?: string + datasourceScope?: string + allowedDatasourceIds?: string[] + expiresAt?: string + revokedAt?: string + createdAt: string + updatedAt: string + sensitivityClassificationGrant?: boolean + datasourceManagementGrant?: boolean +} + +const mockAgents: SkillAgent[] = [ + { id: 'claude', name: 'Claude Code', detected: true, installed: false, installPath: '~/.claude/skills/futrixdata/SKILL.md' }, + { id: 'cursor', name: 'Cursor', detected: true, installed: false, installPath: '~/.cursor/rules/futrixdata.mdc' }, + { id: 'codex', name: 'Codex', detected: false, installed: false, installPath: '~/.codex/skills/futrixdata/SKILL.md' }, + { id: 'opencode', name: 'OpenCode', detected: false, installed: false, installPath: '~/.opencode/skills/futrixdata.md' }, +] + +const mockDetectAIAgents = async (): Promise => cloneJson(mockAgents) +const mockInstallSkill = async (ids: string[]): Promise => cloneJson({ + installed: ids.map(id => { + const agent = mockAgents.find(a => a.id === id) + return { id, name: agent?.name || id, path: agent?.installPath || '', success: true } + }), +}) +const mockUninstallSkill = async (ids: string[]): Promise => cloneJson({ + installed: ids.map(id => { + const agent = mockAgents.find(a => a.id === id) + return { id, name: agent?.name || id, path: agent?.installPath || '', success: true } + }), +}) +const mockSkillPrompted = async (): Promise => false +const mockMarkPrompted = async (): Promise => {} + +const mockMCPAgents: MCPAgent[] = [ + { id: 'claude', name: 'Claude Code', detected: true, installed: false, configPath: '~/.claude/settings.json' }, + { id: 'cursor', name: 'Cursor', detected: true, installed: false, configPath: '~/.cursor/mcp.json' }, + { id: 'codex', name: 'Codex', detected: false, installed: false, configPath: '~/.codex/config.toml' }, + { id: 'opencode', name: 'OpenCode', detected: false, installed: false, configPath: '~/.config/opencode/opencode.json' }, +] + +const mockDetectMCPAgents = async (): Promise => cloneJson(mockMCPAgents) + +const mockGetManualInstallInfo = async (): Promise => + cloneJson(getMockManualInstallInfo()) as ManualInstallInfo +const mockGetManualInstallInfoForKey = async (accessKey: string): Promise => + cloneJson(getMockManualInstallInfoForKey(accessKey)) as ManualInstallInfo +const mockCreateManualAgent = async (name: string): Promise => + cloneJson(createMockManualAgent(name)) as AgentIdentity +const mockRenameAgentIdentity = async (accessKey: string, name: string) => + cloneJson(renameMockAgentIdentity(accessKey, name)) +const mockRevokeAgentIdentity = async (accessKey: string) => + cloneJson(revokeMockAgentIdentity(accessKey)) +const mockUnrevokeAgentIdentity = async (accessKey: string) => + cloneJson(unrevokeMockAgentIdentity(accessKey)) +const mockSetAgentSensitivityGrant = async (accessKey: string, grant: boolean) => + cloneJson(setMockAgentSensitivityGrant(accessKey, grant)) +const mockSetAgentDatasourceManagementGrant = async (accessKey: string, grant: boolean) => + cloneJson(setMockAgentDatasourceManagementGrant(accessKey, grant)) +const mockListAgentIdentities = async (): Promise => + cloneJson(listMockAgentIdentities()) as AgentIdentity[] +const mockInstallMCP = async (ids: string[]): Promise => cloneJson({ + installed: ids.map(id => { + const agent = mockMCPAgents.find(a => a.id === id) + return { id, name: agent?.name || id, path: agent?.configPath || '', success: true } + }), +}) +const mockUninstallMCP = async (ids: string[]): Promise => cloneJson({ + installed: ids.map(id => { + const agent = mockMCPAgents.find(a => a.id === id) + return { id, name: agent?.name || id, path: agent?.configPath || '', success: true } + }), +}) + +export const skillApi = { + detectAIAgents: () => withMock(() => DetectAIAgents(), mockDetectAIAgents) as Promise, + installSkill: (agentIDs: string[]) => + withMock(() => InstallSkill(agentIDs), () => mockInstallSkill(agentIDs)) as Promise, + uninstallSkill: (agentIDs: string[]) => + withMock(() => UninstallSkill(agentIDs), () => mockUninstallSkill(agentIDs)) as Promise, + skillInstallPrompted: () => withMock(() => SkillInstallPrompted(), mockSkillPrompted) as Promise, + markSkillInstallPrompted: () => + withMock(() => MarkSkillInstallPrompted(), mockMarkPrompted) as Promise, + detectMCPAgents: () => withMock(() => DetectMCPAgents(), mockDetectMCPAgents) as Promise, + installMCP: (agentIDs: string[]) => + withMock(() => InstallMCP(agentIDs), () => mockInstallMCP(agentIDs)) as Promise, + uninstallMCP: (agentIDs: string[]) => + withMock(() => UninstallMCP(agentIDs), () => mockUninstallMCP(agentIDs)) as Promise, + authorizeCodexPlugin: () => + withMock(() => AuthorizeCodexPlugin(), () => mockInstallMCP(['codex'])) as Promise, + getManualInstallInfo: () => + withMock(() => GetManualInstallInfo(), mockGetManualInstallInfo) as Promise, + getManualInstallInfoForKey: (accessKey: string) => + withMock( + () => GetManualInstallInfoForKey(accessKey), + () => mockGetManualInstallInfoForKey(accessKey), + ) as Promise, + createManualAgent: (name: string) => + withMock( + () => CreateManualAgent(name), + () => mockCreateManualAgent(name), + ) as Promise, + renameAgentIdentity: (accessKey: string, name: string) => + withMock(() => RenameAgentIdentity(accessKey, name), () => mockRenameAgentIdentity(accessKey, name)) as Promise<{ accessKey: string; name: string }>, + revokeAgentIdentity: (accessKey: string) => + withMock(() => RevokeAgentIdentity(accessKey), () => mockRevokeAgentIdentity(accessKey)) as Promise, + unrevokeAgentIdentity: (accessKey: string) => + withMock(() => UnrevokeAgentIdentity(accessKey), () => mockUnrevokeAgentIdentity(accessKey)) as Promise, + setAgentSensitivityGrant: (accessKey: string, grant: boolean) => + withMock( + () => SetAgentSensitivityGrant(accessKey, grant), + () => mockSetAgentSensitivityGrant(accessKey, grant), + ) as Promise, + setAgentDatasourceManagementGrant: (accessKey: string, grant: boolean) => + withMock( + () => SetAgentDatasourceManagementGrant(accessKey, grant), + () => mockSetAgentDatasourceManagementGrant(accessKey, grant), + ) as Promise, + listAgentIdentities: () => + withMock(() => ListAgentIdentities(), mockListAgentIdentities) as Promise, +} diff --git a/frontend/src/services/api/startupRecovery.ts b/frontend/src/services/api/startupRecovery.ts new file mode 100644 index 0000000..bb6050a --- /dev/null +++ b/frontend/src/services/api/startupRecovery.ts @@ -0,0 +1,55 @@ +import { call, withMock } from './core' + +export type StartupRecoveryAction = 'retry' | 'update_app' | 'open_logs' | 'move_aside_and_restart' + +export type StartupRecoveryError = { + reason: string + message: string + dataPath?: string + dataDir?: string + retentionDir?: string + formatVersion?: number + writerAppVersion?: string + minReaderAppVersion?: string + actions?: StartupRecoveryAction[] + details?: string +} + +export type StartupRecoveryStatus = { + state: 'initializing' | 'ready' | 'failed' + error?: StartupRecoveryError | null + movedAside?: { + retentionDir?: string + } | null +} + +const mockReadyStatus = async (): Promise => ({ state: 'ready' }) +const mockNoop = async (): Promise => {} + +export const startupRecoveryApi = { + startupRecoveryStatus: () => + withMock( + () => call(() => (window as any).go.main.App.StartupRecoveryStatus()), + mockReadyStatus, + ), + startupRecoveryRetry: () => + withMock( + () => call(() => (window as any).go.main.App.StartupRecoveryRetry()), + mockReadyStatus, + ), + startupRecoveryOpenLogs: () => + withMock( + () => call(() => (window as any).go.main.App.StartupRecoveryOpenLogs()), + mockNoop, + ), + startupRecoveryOpenUpdatePage: () => + withMock( + () => call(() => (window as any).go.main.App.StartupRecoveryOpenUpdatePage()), + mockNoop, + ), + startupRecoveryMoveAsideAndRestart: (confirmed: boolean) => + withMock( + () => call(() => (window as any).go.main.App.StartupRecoveryMoveAsideAndRestart(confirmed)), + mockReadyStatus, + ), +} diff --git a/frontend/src/services/api/updater.ts b/frontend/src/services/api/updater.ts new file mode 100644 index 0000000..b575bae --- /dev/null +++ b/frontend/src/services/api/updater.ts @@ -0,0 +1,74 @@ +import { cloneJson, hasWailsBindings, normalizeError } from './core' + +export interface UpdaterResult { + current: string + latest: string + hasUpdate: boolean + downloadUrl: string + platformKey: string + platformLabel: string + releaseNotesUrl: string + authenticated: boolean + lastCheckedAt: number +} + +const emptyResult = (): UpdaterResult => ({ + current: '', + latest: '', + hasUpdate: false, + downloadUrl: '', + platformKey: '', + platformLabel: '', + releaseNotesUrl: '', + authenticated: false, + lastCheckedAt: 0, +}) + +const mockResult: UpdaterResult = { + current: '1.0.27', + latest: '1.0.27', + hasUpdate: false, + downloadUrl: '', + platformKey: 'macos-arm64', + platformLabel: 'macOS (Apple Silicon)', + releaseNotesUrl: 'https://futrixdata.com/#release-notes', + authenticated: true, + lastCheckedAt: Math.floor(Date.now() / 1000), +} + +const wailsApp = (): { + CheckForUpdate?: () => Promise + OpenUpdateDownload?: (url: string) => Promise +} | null => { + if (typeof window === 'undefined') return null + return (window as any).go?.main?.App ?? null +} + +export const updaterApi = { + checkForUpdate: async (): Promise => { + const app = wailsApp() + if (!hasWailsBindings() || !app?.CheckForUpdate) { + return cloneJson(mockResult) + } + try { + const raw = (await app.CheckForUpdate()) as Partial | null + return { ...emptyResult(), ...(raw || {}) } + } catch (err) { + throw new Error(normalizeError(err)) + } + }, + openUpdateDownload: async (url: string): Promise => { + const app = wailsApp() + if (!hasWailsBindings() || !app?.OpenUpdateDownload) { + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer') + } + return + } + try { + await app.OpenUpdateDownload(url) + } catch (err) { + throw new Error(normalizeError(err)) + } + }, +} diff --git a/frontend/src/services/api/userkb.ts b/frontend/src/services/api/userkb.ts new file mode 100644 index 0000000..12d1df6 --- /dev/null +++ b/frontend/src/services/api/userkb.ts @@ -0,0 +1,31 @@ +import type { UserKBCategoryInput, UserKBUploadFileInput, UserKBViewState } from '@/types/userkb' +import { tApp } from '@/modules/i18n/appI18n' + +import { withMock } from './core' + +const emptyState = (): UserKBViewState => ({ + state: { version: 1, categories: [], files: [] }, + aiProviderReady: false, + aiProviderMessage: tApp('kb.providerMessage.runtimeUnavailable'), +}) + +const wails = () => (window as any)?.go?.main?.App + +export const userKBApi = { + userKBList: () => withMock(() => wails().UserKBList(), async () => emptyState()), + + userKBCreateCategory: (input: UserKBCategoryInput) => + withMock(() => wails().UserKBCreateCategory(input), async () => emptyState()), + + userKBUpdateCategory: (id: string, input: UserKBCategoryInput) => + withMock(() => wails().UserKBUpdateCategory(id, input), async () => emptyState()), + + userKBDeleteCategory: (id: string) => + withMock(() => wails().UserKBDeleteCategory(id), async () => emptyState()), + + userKBUploadFiles: (categoryId: string, files: UserKBUploadFileInput[]) => + withMock(() => wails().UserKBUploadFiles(categoryId, files), async () => emptyState()), + + userKBDeleteFile: (fileId: string) => + withMock(() => wails().UserKBDeleteFile(fileId), async () => emptyState()), +} diff --git a/frontend/src/stores/ai-chat.ts b/frontend/src/stores/ai-chat.ts new file mode 100644 index 0000000..fc4823e --- /dev/null +++ b/frontend/src/stores/ai-chat.ts @@ -0,0 +1,491 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { + AiAgentDecision, + AiAgentPlan, + AiApproval, + AiChatInFlightTurn, + AiConsoleResultEffect, + AiConversation, + AiContextChip, + AiMessage, +} from '@/types/ai-chat' +import { tApp } from '@/modules/i18n/appI18n' + +const STORAGE_KEY = 'fd_ai_chat_prefs' +const LEGACY_AUTOEXEC_NOTICE_KEY = 'fd_ai_chat_autoexec_migration_v1' +const DEFAULT_RETENTION = 50 + +type AiChatPrefs = { + defaultOpen: boolean + retention: number +} + +export type LegacyAutoExecuteNotice = { + levels: string[] + // strict is true when the prior pref explicitly auto-ran nothing + // (autoExecuteRiskLevels=[]). Under the new model the default trust + // `cautious` auto-runs low-risk reads, which would silently downgrade + // this user's safety posture. The UI renders a stronger notice for this + // case and nudges the user to pick `approval` trust where desired. + strict?: boolean +} + +type PendingPageContext = { + currentDatasourceId?: string + currentDatasourceType?: string + currentDatabase?: string + currentEntity?: string + currentStatement?: string +} + +const hasStorage = () => { + if (typeof localStorage === 'undefined') return false + return typeof localStorage.getItem === 'function' && typeof localStorage.setItem === 'function' +} + +const loadPrefs = (): AiChatPrefs => { + if (!hasStorage()) { + return { defaultOpen: false, retention: DEFAULT_RETENTION } + } + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) { + return { defaultOpen: false, retention: DEFAULT_RETENTION } + } + try { + const parsed = JSON.parse(raw) + // Clean up any legacy autoExecuteRiskLevels entry still living in the + // cached prefs blob. migrateLegacyAutoExecute (called separately) decides + // whether the user needs a visible notice; here we just make sure the + // stale key is not re-persisted. + if (parsed && 'autoExecuteRiskLevels' in parsed) { + const { autoExecuteRiskLevels: _legacy, ...rest } = parsed + localStorage.setItem(STORAGE_KEY, JSON.stringify(rest)) + } + return { + defaultOpen: parsed.defaultOpen ?? false, + retention: Number(parsed.retention) || DEFAULT_RETENTION, + } + } catch { + return { defaultOpen: false, retention: DEFAULT_RETENTION } + } +} + +const persistPrefs = (prefs: AiChatPrefs) => { + if (!hasStorage()) return + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)) +} + +// migrateLegacyAutoExecute inspects the stored prefs blob for the legacy +// `autoExecuteRiskLevels` preference and — if the user had customized it +// beyond the old default of just `["low"]` — records a one-shot notice so +// they can re-pick a per-datasource trust level instead of being silently +// downgraded. Returns the notice that should be surfaced to the user, or +// null if no migration attention is required. +const migrateLegacyAutoExecute = (): LegacyAutoExecuteNotice | null => { + if (!hasStorage()) return null + // An already-recorded notice takes precedence; the user hasn't dismissed + // it yet, so keep showing it on subsequent loads. + try { + const existing = localStorage.getItem(LEGACY_AUTOEXEC_NOTICE_KEY) + if (existing) { + const parsed = JSON.parse(existing) + if (Array.isArray(parsed?.levels)) { + return { + levels: parsed.levels.map(String), + strict: Boolean(parsed?.strict), + } + } + } + } catch { + // fall through and try to derive from the prefs blob + } + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + try { + const parsed = JSON.parse(raw) + const levels = parsed?.autoExecuteRiskLevels + if (!Array.isArray(levels)) return null + const normalized = levels + .map((lvl) => String(lvl || '').trim().toLowerCase()) + .filter(Boolean) + // The previous default was `["low"]` — auto-running reads only. If the + // user stayed on that default, the new default trust level (`cautious`) + // behaves identically and no notice is needed. + const isDefault = normalized.length === 1 && normalized[0] === 'low' + if (isDefault) return null + // Empty list meant "auto-run nothing" — the strictest prior setting. The + // new `cautious` default auto-runs low-risk reads, so we surface a + // dedicated notice nudging the user to pick `approval` trust to preserve + // that posture. + const strict = normalized.length === 0 + const notice: LegacyAutoExecuteNotice = { levels: normalized, strict } + localStorage.setItem(LEGACY_AUTOEXEC_NOTICE_KEY, JSON.stringify(notice)) + return notice + } catch { + return null + } +} + +const clearLegacyAutoExecuteNotice = () => { + if (!hasStorage()) return + localStorage.removeItem(LEGACY_AUTOEXEC_NOTICE_KEY) +} + +const makeId = (prefix: string) => + `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` +const makeTitle = (content: string) => { + const base = content.trim().split('\n').find((line) => line.trim()) || tApp('ai.sidebar.newChat') + return base.length > 36 ? `${base.slice(0, 36).trim()}…` : base +} + +export const useAiChatStore = defineStore('ai-chat', () => { + // Resolve the legacy auto-execute notice BEFORE loadPrefs runs, since + // loadPrefs re-persists the prefs blob without the legacy key. Reading it + // afterwards would see an already-scrubbed value. + const legacyAutoExecuteNotice = ref(migrateLegacyAutoExecute()) + const prefs = ref(loadPrefs()) + const isOpen = ref(prefs.value.defaultOpen) + const conversations = ref([]) + const messagesById = ref>({}) + const activeId = ref(null) + const pendingApprovalByConversationId = ref>({}) + const consoleResult = ref(null) + const inFlight = ref(null) + const cancelPendingTurnId = ref(null) + const draft = ref('') + const pendingContext = ref(null) + const pendingPageContext = ref(null) + const autoSend = ref(false) + + const setDefaultOpen = (value: boolean) => { + prefs.value.defaultOpen = value + isOpen.value = value + persistPrefs(prefs.value) + } + + const setDraft = (value: string) => { + draft.value = value + } + + const setPendingContext = (value: any) => { + pendingContext.value = value + } + + const setPendingPageContext = (value: PendingPageContext | null) => { + pendingPageContext.value = value + } + + const setAutoSend = (value: boolean) => { + autoSend.value = value + } + + const setRetentionLimit = (value: number) => { + prefs.value.retention = Math.max(1, Math.floor(value || DEFAULT_RETENTION)) + persistPrefs(prefs.value) + if (conversations.value.length > prefs.value.retention) { + const trimmed = conversations.value.splice(0, conversations.value.length - prefs.value.retention) + trimmed.forEach((item) => { + delete messagesById.value[item.id] + delete pendingApprovalByConversationId.value[item.id] + }) + if (activeId.value && !messagesById.value[activeId.value]) { + activeId.value = conversations.value.at(-1)?.id || null + } + } + } + + const toggleOpen = () => { + isOpen.value = !isOpen.value + } + + const setOpen = (value: boolean) => { + isOpen.value = value + } + + const clearActive = () => { + activeId.value = null + } + + const setInFlight = (turn: AiChatInFlightTurn) => { + inFlight.value = turn + } + + const setInFlightStreamId = (turnId: string, streamId: string) => { + if (!inFlight.value || inFlight.value.turnId !== turnId) return + inFlight.value = { ...inFlight.value, streamId } + } + + const applyInFlightProgress = (turnId: string, message: string) => { + const text = String(message || '').trim() + if (!text) return + if (!inFlight.value || inFlight.value.turnId !== turnId) return + + const conversationId = inFlight.value.conversationId + const messageId = inFlight.value.assistantMessageId + const existing = messagesById.value[conversationId] || [] + const idx = existing.findIndex((msg) => msg.id === messageId) + if (idx === -1) return + const msg = existing[idx] + if (msg.role !== 'assistant') return + + const placeholder = String(inFlight.value.progressPlaceholder || '') + const current = String(msg.content || '') + if (current && (!placeholder || current !== placeholder)) return + + const updated: AiMessage = { ...msg, content: text } + messagesById.value[conversationId] = [...existing.slice(0, idx), updated, ...existing.slice(idx + 1)] + inFlight.value = { ...inFlight.value, progressPlaceholder: text } + } + + const clearInFlight = (turnId?: string) => { + if (!turnId) { + inFlight.value = null + return + } + if (inFlight.value?.turnId !== turnId) return + inFlight.value = null + } + + const setCancelPendingTurnId = (turnId: string | null) => { + cancelPendingTurnId.value = turnId + } + + const createConversation = (title: string) => { + const now = Date.now() + const convo: AiConversation = { + id: makeId('chat'), + title: title || tApp('ai.sidebar.newChat'), + createdAt: now, + updatedAt: now, + } + conversations.value.push(convo) + activeId.value = convo.id + messagesById.value[convo.id] = messagesById.value[convo.id] || [] + + if (conversations.value.length > prefs.value.retention) { + const trimmed = conversations.value.splice(0, conversations.value.length - prefs.value.retention) + trimmed.forEach((item) => { + delete messagesById.value[item.id] + delete pendingApprovalByConversationId.value[item.id] + }) + } + return convo + } + + const deleteConversation = (id: string) => { + conversations.value = conversations.value.filter((c) => c.id !== id) + delete messagesById.value[id] + delete pendingApprovalByConversationId.value[id] + if (activeId.value === id) { + activeId.value = conversations.value.at(-1)?.id || null + } + } + + const setActive = (id: string) => { + activeId.value = id + } + + const sendMessage = (content: string, context: AiContextChip[], implicitStatement?: string) => { + const now = Date.now() + let id = activeId.value + if (!id) { + id = createConversation(makeTitle(content)).id + } + const existing = messagesById.value[id] || [] + if (!existing.length) { + const convo = conversations.value.find((item) => item.id === id) + if (convo) { + convo.title = makeTitle(content) + } + } + const userMsg: AiMessage = { + id: makeId('msg'), + role: 'user', + content, + createdAt: now, + context, + implicitStatement, + } + messagesById.value[id] = [...existing, userMsg] + const convo = conversations.value.find((item) => item.id === id) + if (convo) { + convo.updatedAt = now + } + return userMsg + } + + const addAssistantMessage = ( + content: string, + metadata?: { agent?: AiAgentDecision; plan?: AiAgentPlan }, + ) => { + const id = activeId.value + if (!id) return null + const now = Date.now() + const assistantMsg: AiMessage = { + id: makeId('msg'), + role: 'assistant', + content, + createdAt: now, + context: [], + agent: metadata?.agent, + plan: metadata?.plan, + } + messagesById.value[id] = [...(messagesById.value[id] || []), assistantMsg] + const convo = conversations.value.find((item) => item.id === id) + if (convo) { + convo.updatedAt = now + } + return assistantMsg + } + + const startAssistantMessage = ( + conversationId: string, + metadata?: { agent?: AiAgentDecision; plan?: AiAgentPlan }, + ) => { + const id = conversationId || activeId.value + if (!id) return null + const now = Date.now() + const assistantMsg: AiMessage = { + id: makeId('msg'), + role: 'assistant', + content: '', + createdAt: now, + context: [], + agent: metadata?.agent, + plan: metadata?.plan, + } + messagesById.value[id] = [...(messagesById.value[id] || []), assistantMsg] + const convo = conversations.value.find((item) => item.id === id) + if (convo) { + convo.updatedAt = now + } + return assistantMsg + } + + const removeMessage = (conversationId: string, messageId: string) => { + if (!conversationId || !messageId) return + const existing = messagesById.value[conversationId] || [] + const idx = existing.findIndex((msg) => msg.id === messageId) + if (idx === -1) return + messagesById.value[conversationId] = [...existing.slice(0, idx), ...existing.slice(idx + 1)] + } + + const appendAssistantDelta = (conversationId: string, messageId: string, delta: string) => { + if (!conversationId || !messageId || !delta) return + const existing = messagesById.value[conversationId] || [] + const idx = existing.findIndex((msg) => msg.id === messageId) + if (idx === -1) return + const msg = existing[idx] + if (msg.role !== 'assistant') return + const placeholder = String(inFlight.value?.progressPlaceholder || '') + const isInFlightTarget = inFlight.value + && inFlight.value.conversationId === conversationId + && inFlight.value.assistantMessageId === messageId + const base = isInFlightTarget && placeholder && String(msg.content || '') === placeholder ? '' : (msg.content || '') + const updated: AiMessage = { ...msg, content: base + delta } + messagesById.value[conversationId] = [...existing.slice(0, idx), updated, ...existing.slice(idx + 1)] + if (isInFlightTarget && placeholder) { + inFlight.value = { ...inFlight.value, progressPlaceholder: '' } + } + } + + const setAssistantContent = (conversationId: string, messageId: string, content: string) => { + if (!conversationId || !messageId) return + const existing = messagesById.value[conversationId] || [] + const idx = existing.findIndex((msg) => msg.id === messageId) + if (idx === -1) return + const msg = existing[idx] + if (msg.role !== 'assistant') return + const updated: AiMessage = { ...msg, content: content || '' } + messagesById.value[conversationId] = [...existing.slice(0, idx), updated, ...existing.slice(idx + 1)] + } + + const setAssistantMetadata = ( + conversationId: string, + messageId: string, + metadata: { agent?: AiAgentDecision; plan?: AiAgentPlan }, + ) => { + if (!conversationId || !messageId) return + const existing = messagesById.value[conversationId] || [] + const idx = existing.findIndex((msg) => msg.id === messageId) + if (idx === -1) return + const msg = existing[idx] + if (msg.role !== 'assistant') return + const updated: AiMessage = { + ...msg, + agent: metadata.agent, + plan: metadata.plan, + } + messagesById.value[conversationId] = [...existing.slice(0, idx), updated, ...existing.slice(idx + 1)] + } + + const setPendingApproval = (conversationId: string, approval: AiApproval | null) => { + if (!conversationId) return + pendingApprovalByConversationId.value[conversationId] = approval + } + + const clearPendingApproval = (conversationId: string) => { + if (!conversationId) return + delete pendingApprovalByConversationId.value[conversationId] + } + + const setConsoleResult = (payload: AiConsoleResultEffect | null) => { + consoleResult.value = payload + } + + const clearConsoleResult = () => { + consoleResult.value = null + } + + const dismissLegacyAutoExecuteNotice = () => { + legacyAutoExecuteNotice.value = null + clearLegacyAutoExecuteNotice() + } + + return { + prefs, + isOpen, + conversations, + messagesById, + activeId, + pendingApprovalByConversationId, + consoleResult, + inFlight, + cancelPendingTurnId, + setDefaultOpen, + setRetentionLimit, + toggleOpen, + setOpen, + clearActive, + setInFlight, + setInFlightStreamId, + applyInFlightProgress, + clearInFlight, + setCancelPendingTurnId, + createConversation, + deleteConversation, + setActive, + sendMessage, + addAssistantMessage, + startAssistantMessage, + removeMessage, + appendAssistantDelta, + setAssistantContent, + setAssistantMetadata, + setPendingApproval, + clearPendingApproval, + setConsoleResult, + clearConsoleResult, + draft, + pendingContext, + pendingPageContext, + setDraft, + setPendingContext, + setPendingPageContext, + autoSend, + setAutoSend, + legacyAutoExecuteNotice, + dismissLegacyAutoExecuteNotice, + } +}) diff --git a/frontend/src/stores/app.test.ts b/frontend/src/stores/app.test.ts new file mode 100644 index 0000000..2b443aa --- /dev/null +++ b/frontend/src/stores/app.test.ts @@ -0,0 +1,40 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useAppStore } from './app' + +describe('app store datasource entity cache', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('preserves cached entity names verbatim when switching back to a datasource', () => { + const store = useAppStore() + + store.saveEntityListState('ds_sql', { + items: [' spaced table ', ' '], + cursor: '', + done: true, + pattern: '', + }) + + store.restoreDatasourceEntityState('ds_sql', '') + + expect(store.entities).toEqual([' spaced table ', ' ']) + }) + + it('can restore a cached datasource entity list even when local filter patterns differ', () => { + const store = useAppStore() + + store.saveEntityListState('ds_es', { + items: ['futrixdata-demo-1', 'logs-prod-2026'], + cursor: '', + done: true, + pattern: '', + }) + + store.restoreDatasourceEntityState('ds_es', 'demo', { allowPatternMismatch: true }) + + expect(store.entities).toEqual(['futrixdata-demo-1', 'logs-prod-2026']) + }) +}) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts new file mode 100644 index 0000000..224957b --- /dev/null +++ b/frontend/src/stores/app.ts @@ -0,0 +1,410 @@ +import { defineStore } from 'pinia' +import { ref, reactive, watch } from 'vue' +import type { AIConfig, DataSource, DatasourceMetrics, MongoBrowseState } from '@/types' +import { api } from '@/services/api' + +type ElasticsearchIndexMetaState = { + health: string + storeSize: string +} + +type ElasticsearchFieldSelectionState = Record + +type EntityListState = { + items: string[] + cursor: string + done: boolean + pattern: string + kinds?: Record +} + +type RedisTreePrefixState = { + cursor: string + done: boolean +} + +type RedisTreeState = { + keys: string[] + expanded: string[] + prefixState: Record + separator: string + maxDepth: number + pattern: string +} + +const normalizeRedisTreeValues = (values: string[] | undefined) => + Array.isArray(values) + ? values.filter((value) => value !== null && value !== undefined).map((value) => String(value)) + : [] + +const normalizeEntityListItems = (values: string[] | undefined) => + Array.isArray(values) + ? values.filter((value) => value !== null && value !== undefined).map((value) => String(value)) + : [] + +const normalizeElasticsearchFieldSelectionState = (state: ElasticsearchFieldSelectionState | undefined) => { + const normalized: ElasticsearchFieldSelectionState = {} + Object.entries(state || {}).forEach(([name, values]) => { + const trimmedName = String(name || '').trim() + if (!trimmedName) return + const fields = Array.isArray(values) + ? Array.from( + new Set( + values + .map((value) => String(value || '').trim()) + .filter(Boolean), + ), + ) + : [] + if (!fields.length) return + normalized[trimmedName] = fields + }) + return normalized +} + +export const useAppStore = defineStore('app', () => { + const datasources = ref([]) + const aiConfigs = ref([]) + const embeddingConfigs = ref([]) + const current = ref(null) + const formMode = ref<'create' | 'edit'>('create') + const formId = ref(null) + const status = reactive>({}) + const statusDetails = reactive>({}) + const statusCheckedAt = reactive>({}) + const datasourceMetrics = reactive>({}) + const entities = ref([]) + const entityKinds = reactive>({}) + const entityListStateByDatasource = reactive>({}) + const redisTreeStateByDatasource = reactive>({}) + const elasticsearchIndexMeta = reactive>({}) + const elasticsearchIndexMetaByDatasource = reactive>>({}) + const elasticsearchFieldSelections = reactive>({}) + const elasticsearchFieldSelectionsByDatasource = reactive>({}) + const selectedEntity = ref('') + const listSearch = ref('') + const listSort = ref<'name-asc' | 'name-desc' | 'type-asc' | 'status'>('name-asc') + const mongoFieldsByDatasource = reactive>>({}) + const mongoIndexFieldsByDatasource = reactive>>({}) + const mongoBrowse = reactive({ + active: false, + collection: '', + pageSize: 50, + pageIndex: 0, + firstId: null, + lastId: null, + lastCount: 0, + }) + const mongoExecutePending = ref('') + const mongoDatabase = ref('') + const mongoDatabaseDraft = ref('') + const mongoDatabases = ref([]) + const mongoDatabaseByDatasource = reactive>({}) + const mongoDatabaseMode = ref(false) + const mongoDatabaseSelectable = ref(false) + const mongoDatabaseError = ref('') + const lastConsoleError = ref('') + + const notice = reactive({ + message: '', + type: '', + }) + let noticeTimer: number | null = null + + const replaceElasticsearchIndexMeta = (next: Record = {}) => { + Object.keys(elasticsearchIndexMeta).forEach((key) => delete elasticsearchIndexMeta[key]) + Object.entries(next || {}).forEach(([key, value]) => { + const name = String(key || '').trim() + if (!name) return + elasticsearchIndexMeta[name] = { + health: String(value?.health || ''), + storeSize: String(value?.storeSize || ''), + } + }) + } + + const replaceElasticsearchFieldSelections = (next: ElasticsearchFieldSelectionState = {}) => { + Object.keys(elasticsearchFieldSelections).forEach((key) => delete elasticsearchFieldSelections[key]) + Object.entries(normalizeElasticsearchFieldSelectionState(next)).forEach(([key, value]) => { + elasticsearchFieldSelections[key] = value + }) + } + + const snapshotElasticsearchFieldSelections = () => + normalizeElasticsearchFieldSelectionState(elasticsearchFieldSelections) + + const saveElasticsearchFieldSelectionsState = ( + datasourceId: string, + next: ElasticsearchFieldSelectionState = snapshotElasticsearchFieldSelections(), + ) => { + const key = String(datasourceId || '').trim() + if (!key) return + const normalized = normalizeElasticsearchFieldSelectionState(next) + const existing = normalizeElasticsearchFieldSelectionState(elasticsearchFieldSelectionsByDatasource[key]) + const merged = { + ...existing, + ...normalized, + } + if (!Object.keys(merged).length) { + delete elasticsearchFieldSelectionsByDatasource[key] + return + } + elasticsearchFieldSelectionsByDatasource[key] = merged + } + + const restoreElasticsearchFieldSelectionsState = ( + datasourceId: string, + options: { allowInitialFallback?: boolean } = {}, + ) => { + const key = String(datasourceId || '').trim() + const snapshot = key ? elasticsearchFieldSelectionsByDatasource[key] : null + if (snapshot) { + replaceElasticsearchFieldSelections(snapshot) + return + } + if (options.allowInitialFallback) { + const fallback = snapshotElasticsearchFieldSelections() + if (Object.keys(fallback).length) { + if (key) { + elasticsearchFieldSelectionsByDatasource[key] = fallback + } + replaceElasticsearchFieldSelections(fallback) + return + } + } + replaceElasticsearchFieldSelections({}) + } + + const restoreDatasourceEntityState = ( + datasourceId: string, + pattern = '', + options: { allowPatternMismatch?: boolean } = {}, + ) => { + const key = String(datasourceId || '').trim() + const snapshot = key ? entityListStateByDatasource[key] : null + const normalizedPattern = String(pattern || '').trim() + const allowPatternMismatch = Boolean(options.allowPatternMismatch) + const canRestore = snapshot && (snapshot.pattern === normalizedPattern || allowPatternMismatch) + entities.value = canRestore ? [...snapshot.items] : [] + for (const k of Object.keys(entityKinds)) delete entityKinds[k] + if (canRestore && snapshot.kinds) { + for (const [name, kind] of Object.entries(snapshot.kinds)) { + if (name && kind) entityKinds[name] = kind + } + } + replaceElasticsearchIndexMeta(key ? elasticsearchIndexMetaByDatasource[key] || {} : {}) + } + + const saveEntityListState = (datasourceId: string, state: Partial) => { + const key = String(datasourceId || '').trim() + if (!key) return + const kinds = Object.keys(entityKinds).length > 0 ? { ...entityKinds } : undefined + entityListStateByDatasource[key] = { + items: normalizeEntityListItems(state.items), + cursor: String(state.cursor || ''), + done: Boolean(state.done), + pattern: String(state.pattern || '').trim(), + kinds, + } + } + + const saveElasticsearchIndexMetaState = ( + datasourceId: string, + next: Record = {}, + ) => { + const key = String(datasourceId || '').trim() + if (!key) return + const normalized: Record = {} + Object.entries(next || {}).forEach(([name, value]) => { + const trimmedName = String(name || '').trim() + if (!trimmedName) return + normalized[trimmedName] = { + health: String(value?.health || ''), + storeSize: String(value?.storeSize || ''), + } + }) + if (!Object.keys(normalized).length) { + delete elasticsearchIndexMetaByDatasource[key] + return + } + elasticsearchIndexMetaByDatasource[key] = normalized + } + + const restoreRedisTreeState = (datasourceId: string): RedisTreeState => { + const key = String(datasourceId || '').trim() + const snapshot = key ? redisTreeStateByDatasource[key] : null + if (!snapshot) { + return { + keys: [], + expanded: [], + prefixState: {}, + separator: ':', + maxDepth: 5, + pattern: '', + } + } + const prefixState: Record = {} + Object.entries(snapshot.prefixState || {}).forEach(([prefix, value]) => { + prefixState[String(prefix)] = { + cursor: String(value?.cursor || ''), + done: Boolean(value?.done), + } + }) + return { + keys: normalizeRedisTreeValues(snapshot.keys), + expanded: normalizeRedisTreeValues(snapshot.expanded), + prefixState, + separator: String(snapshot.separator || ':'), + maxDepth: Number(snapshot.maxDepth || 5), + pattern: String(snapshot.pattern || '').trim(), + } + } + + const saveRedisTreeState = (datasourceId: string, state: Partial = {}) => { + const key = String(datasourceId || '').trim() + if (!key) return + const prefixState: Record = {} + Object.entries(state.prefixState || {}).forEach(([prefix, value]) => { + prefixState[String(prefix)] = { + cursor: String(value?.cursor || ''), + done: Boolean(value?.done), + } + }) + redisTreeStateByDatasource[key] = { + keys: normalizeRedisTreeValues(state.keys), + expanded: normalizeRedisTreeValues(state.expanded), + prefixState, + separator: String(state.separator || ':'), + maxDepth: Number(state.maxDepth || 5), + pattern: String(state.pattern || '').trim(), + } + } + + const setNotice = (message: string, type = '') => { + if (noticeTimer) { + window.clearTimeout(noticeTimer) + noticeTimer = null + } + notice.message = message + notice.type = type + if (!message) { + return + } + noticeTimer = window.setTimeout(() => { + notice.message = '' + notice.type = '' + noticeTimer = null + }, type === 'error' ? 8000 : 4000) + } + + const setCurrentDatasource = (ds: DataSource | null) => { + const previousDatasourceId = String(current.value?.id || '').trim() + const allowInitialFieldSelectionFallback = !previousDatasourceId + if (previousDatasourceId) { + saveElasticsearchFieldSelectionsState(previousDatasourceId) + } + current.value = ds + selectedEntity.value = '' + restoreDatasourceEntityState(String(ds?.id || ''), '') + restoreElasticsearchFieldSelectionsState(String(ds?.id || ''), { + allowInitialFallback: allowInitialFieldSelectionFallback, + }) + } + + watch( + elasticsearchFieldSelections, + () => { + const datasourceId = String(current.value?.id || '').trim() + if (!datasourceId) return + saveElasticsearchFieldSelectionsState(datasourceId) + }, + { deep: true }, + ) + + const markDatasourceActive = (id: string) => { + if (!id) return + status[id] = 'connected' + statusDetails[id] = '' + statusCheckedAt[id] = Date.now() + } + + const resetMongoBrowse = () => { + mongoBrowse.active = false + mongoBrowse.collection = '' + mongoBrowse.pageIndex = 0 + mongoBrowse.pageSize = 50 + mongoBrowse.firstId = null + mongoBrowse.lastId = null + mongoBrowse.lastCount = 0 + } + + const loadDatasources = async () => { + datasources.value = await api.listDatasources() + } + + const loadAIConfigs = async () => { + try { + aiConfigs.value = await api.listAIConfigs() + } catch { + aiConfigs.value = [] + } + } + + const loadEmbeddingConfigs = async () => { + try { + embeddingConfigs.value = await api.listEmbeddingConfigs() + } catch { + embeddingConfigs.value = [] + } + } + + return { + datasources, + aiConfigs, + embeddingConfigs, + current, + formMode, + formId, + status, + statusDetails, + statusCheckedAt, + datasourceMetrics, + entities, + entityKinds, + entityListStateByDatasource, + redisTreeStateByDatasource, + elasticsearchIndexMeta, + elasticsearchIndexMetaByDatasource, + elasticsearchFieldSelections, + elasticsearchFieldSelectionsByDatasource, + selectedEntity, + listSearch, + listSort, + mongoFieldsByDatasource, + mongoIndexFieldsByDatasource, + mongoBrowse, + mongoExecutePending, + mongoDatabase, + mongoDatabaseDraft, + mongoDatabases, + mongoDatabaseByDatasource, + mongoDatabaseMode, + mongoDatabaseSelectable, + mongoDatabaseError, + lastConsoleError, + notice, + setNotice, + setCurrentDatasource, + restoreDatasourceEntityState, + saveEntityListState, + saveElasticsearchIndexMetaState, + saveElasticsearchFieldSelectionsState, + restoreRedisTreeState, + saveRedisTreeState, + markDatasourceActive, + resetMongoBrowse, + loadDatasources, + loadAIConfigs, + loadEmbeddingConfigs, + } +}) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..82e6474 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,290 @@ +import { computed, getCurrentScope, onScopeDispose, ref } from 'vue' +import { defineStore } from 'pinia' + +import { tApp } from '@/modules/i18n/appI18n' +import { evaluateLicense, resolvePlanLimitMessage } from '@/modules/plan/limits' +import { api } from '@/services/api' +import type { AuthDeviceInfo, AuthLicense, AuthLoginStart, AuthState } from '@/types' + +const normalizeAuthState = (next?: Partial | null): AuthState => ({ + deviceId: String(next?.deviceId || ''), + pendingLogin: next?.pendingLogin ?? null, + session: next?.session ?? null, + trial: next?.trial ?? null, +}) + +const errorMessage = (err: unknown) => { + const raw = err instanceof Error ? err.message : String(err || '') + return resolvePlanLimitMessage(raw, undefined) || raw +} + +export const useAuthStore = defineStore('auth', () => { + const state = ref(normalizeAuthState()) + const ready = ref(false) + const restoring = ref(false) + const loginBusy = ref(false) + const error = ref('') + const loginUrl = ref('') + const manualCode = ref('') + const devices = ref([]) + const deviceLimit = ref(0) + const devicesLoading = ref(false) + let pollTimer: number | null = null + + const isAuthenticated = computed(() => Boolean(state.value.session)) + const currentUser = computed(() => state.value.session?.user ?? null) + const currentLicense = computed(() => state.value.session?.license ?? null) + const currentTrial = computed(() => state.value.trial ?? null) + // nowMs is a reactive clock. evaluateLicense() reads the current time, so + // without a reactive `now` dependency the effective entitlement would only + // recompute on a license/state mutation — a session active at render time + // would stay "active" in the UI after expiresAt passes, while backend gates + // (which call planlimits.EvaluateLicense per request) already treat it as + // expired. Ticking nowMs forces effectiveLicense/effectivePlan to invalidate + // around the expiry boundary so MyView, gate copy, and Pro affordances flip + // in step with the backend. + const nowMs = ref(Date.now()) + // 30s is well below typical Pro→Free transition latencies the user would + // notice, and re-evaluating the cheap evaluateLicense() at this cadence has + // no measurable cost. + const NOW_TICK_MS = 30_000 + let nowTimer: ReturnType | null = null + const tickNow = () => { nowMs.value = Date.now() } + // __setNowForTest lets tests simulate clock progression deterministically + // (vitest fake timers don't drive computed re-evaluation unless the ref + // value itself changes). Production callers should never invoke this. + const __setNowForTest = (value: number) => { nowMs.value = value } + if (typeof setInterval !== 'undefined') { + nowTimer = setInterval(tickNow, NOW_TICK_MS) + } + const stopNowTicker = () => { + if (nowTimer != null) { + clearInterval(nowTimer) + nowTimer = null + } + } + // Pinia setup stores run inside an effect scope; tying cleanup to the scope + // means HMR replacements, vitest re-`createPinia()` between tests, and any + // future multi-mount setup all dispose the ticker instead of orphaning a + // setInterval that keeps mutating a stale ref. + if (getCurrentScope()) { + onScopeDispose(stopNowTicker) + } + // effectiveLicense reconciles the raw stored license with the current time + // so callers see an expired Pro session as Free with pro_expired status. + const effectiveLicense = computed(() => evaluateLicense(currentLicense.value, nowMs.value, currentTrial.value)) + // No signed-in session still resolves through the same local entitlement: + // active local trial behaves like Pro, otherwise the app falls back to Free. + const effectivePlan = computed(() => effectiveLicense.value.effectivePlan) + + const applyLicenseUpdate = (next: AuthLicense | null | undefined) => { + if (!next) return + if (!state.value.session) return + const current = state.value.session.license + if ( + current + && current.plan === next.plan + && current.status === next.status + && current.expiresAt === next.expiresAt + ) { + return + } + state.value = { + ...state.value, + session: { + ...state.value.session, + license: { + plan: String(next.plan ?? ''), + status: String(next.status ?? ''), + expiresAt: Number(next.expiresAt ?? 0) || 0, + }, + }, + } + } + + const applyState = (next?: Partial | null) => { + state.value = normalizeAuthState(next) + loginUrl.value = state.value.pendingLogin?.loginUrl || '' + if (!state.value.session) { + devices.value = [] + deviceLimit.value = 0 + } + } + + const stopPolling = () => { + if (pollTimer) { + window.clearInterval(pollTimer) + pollTimer = null + } + } + + const loadDevices = async () => { + if (!isAuthenticated.value) { + devices.value = [] + deviceLimit.value = 0 + return + } + devicesLoading.value = true + try { + const result = await api.listAuthDevices() + devices.value = result.devices || [] + deviceLimit.value = result.limit || 0 + applyLicenseUpdate(result.license) + } finally { + devicesLoading.value = false + } + } + + const restore = async () => { + restoring.value = true + try { + const next = await api.ensureAuthenticated() + applyState(next) + error.value = '' + } catch (err) { + const message = errorMessage(err) + if (message.toLowerCase().includes('login required')) { + let current: Partial | null = null + try { + current = await api.currentAuth() + } catch { + current = state.value + } + applyState({ ...current, session: null, pendingLogin: null }) + error.value = '' + } else { + applyState({ ...state.value, session: null }) + error.value = message + } + } finally { + ready.value = true + restoring.value = false + } + } + + const pollOnce = async () => { + const result = await api.pollAuthLogin() + if (result.status === 'completed' && result.code) { + stopPolling() + const next = await api.completeAuthLogin(result.code) + applyState(next) + error.value = '' + await loadDevices() + return true + } + if (result.status === 'expired') { + stopPolling() + error.value = tApp('auth.login.expired') + applyState({ ...state.value, pendingLogin: null }) + return true + } + return false + } + + const startLogin = async (input: { noBrowser?: boolean } = {}): Promise => { + loginBusy.value = true + error.value = '' + stopPolling() + try { + const started = await api.startAuthLogin(input) + loginUrl.value = started.loginUrl + state.value = { + ...state.value, + pendingLogin: { + sessionId: started.sessionId, + codeVerifier: '', + loginUrl: started.loginUrl, + }, + } + pollTimer = window.setInterval(() => { + void pollOnce().catch((err) => { + stopPolling() + error.value = errorMessage(err) + }) + }, 2000) + return started + } catch (err) { + error.value = errorMessage(err) + throw err + } finally { + loginBusy.value = false + } + } + + const completeManualCode = async (code: string) => { + loginBusy.value = true + error.value = '' + stopPolling() + try { + const next = await api.completeAuthLogin(code) + applyState(next) + manualCode.value = '' + await loadDevices() + } catch (err) { + error.value = errorMessage(err) + throw err + } finally { + loginBusy.value = false + } + } + + const logout = async () => { + stopPolling() + loginBusy.value = true + try { + const next = await api.logoutAuth() + applyState(next) + error.value = '' + } finally { + loginBusy.value = false + } + } + + const removeDevice = async (deviceId: string) => { + const result = await api.removeAuthDevice(deviceId) + devices.value = result.devices || [] + deviceLimit.value = result.limit || 0 + applyLicenseUpdate(result.license) + } + + const applyRuntimeState = async (next?: Partial | null) => { + stopPolling() + applyState(next) + error.value = '' + await loadDevices() + } + + const applyRuntimeError = (message: string) => { + stopPolling() + error.value = String(message || '') + } + + return { + state, + ready, + restoring, + loginBusy, + error, + loginUrl, + manualCode, + devices, + deviceLimit, + devicesLoading, + isAuthenticated, + currentUser, + currentLicense, + effectiveLicense, + effectivePlan, + __setNowForTest, + stopNowTicker, + restore, + startLogin, + completeManualCode, + loadDevices, + logout, + removeDevice, + applyRuntimeState, + applyRuntimeError, + stopPolling, + } +}) diff --git a/frontend/src/stores/redis-protobuf.ts b/frontend/src/stores/redis-protobuf.ts new file mode 100644 index 0000000..5755528 --- /dev/null +++ b/frontend/src/stores/redis-protobuf.ts @@ -0,0 +1,131 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { api } from '@/services/api' +import type { RedisProtobufSchema } from '@/services/api/redisProtobuf' + +type LoadState = 'idle' | 'loading' | 'ready' | 'error' + +export const useRedisProtobufStore = defineStore('redis-protobuf', () => { + // Cache schemas per datasource id; key '' means "global" / not yet scoped. + const schemasByDatasource = ref>({}) + const stateByDatasource = ref>({}) + const errorByDatasource = ref>({}) + + // In-flight requests keyed by datasource id to dedupe concurrent calls. + const inflight = new Map>() + // Per-key request token: force-refresh can race with an earlier in-flight + // call. The handler only writes if its token still matches the latest one, + // so a slow earlier response can't clobber a fresher refresh. + const requestToken = new Map() + + const schemasFor = (datasourceId: string): RedisProtobufSchema[] => { + return schemasByDatasource.value[String(datasourceId || '')] || [] + } + + const stateFor = (datasourceId: string): LoadState => { + return stateByDatasource.value[String(datasourceId || '')] || 'idle' + } + + const ensureLoaded = async (datasourceId: string, force = false): Promise => { + const key = String(datasourceId || '') + if (!force) { + if (stateByDatasource.value[key] === 'ready') return schemasFor(key) + const existing = inflight.get(key) + if (existing) return existing + } + stateByDatasource.value = { ...stateByDatasource.value, [key]: 'loading' } + const token = (requestToken.get(key) ?? 0) + 1 + requestToken.set(key, token) + const promise = api + .listRedisProtobufSchemas(key) + .then((list) => { + if (requestToken.get(key) !== token) return list + schemasByDatasource.value = { ...schemasByDatasource.value, [key]: list } + stateByDatasource.value = { ...stateByDatasource.value, [key]: 'ready' } + errorByDatasource.value = { ...errorByDatasource.value, [key]: '' } + return list + }) + .catch((err: unknown) => { + if (requestToken.get(key) === token) { + const msg = err instanceof Error ? err.message : String(err) + stateByDatasource.value = { ...stateByDatasource.value, [key]: 'error' } + errorByDatasource.value = { ...errorByDatasource.value, [key]: msg } + } + throw err + }) + .finally(() => { + if (inflight.get(key) === promise) inflight.delete(key) + }) + inflight.set(key, promise) + return promise + } + + const save = async (payload: { + id?: string + datasourceId: string + name: string + content: string + }): Promise => { + const saved = await api.saveRedisProtobufSchema(payload) + await refreshAffectedBuckets(saved.datasourceId || '') + return saved + } + + const remove = async (id: string, datasourceId: string): Promise => { + await api.deleteRedisProtobufSchema(id) + await refreshAffectedBuckets(datasourceId || '') + } + + // Refresh every cached bucket whose list could include the changed schema. + // Backend semantics (app_redisproto.go): empty selector returns the full + // catalogue (all scopes); non-empty returns that scope's schemas merged + // with all globals. So: + // - global write (datasourceId === ''): every cached bucket goes stale + // (the '' "list everything" bucket and every scoped bucket that + // surfaces globals). + // - scoped write: the affected scoped bucket plus the '' bucket + // (which lists everything). + const refreshAffectedBuckets = async (datasourceId: string) => { + const cachedKeys = new Set(Object.keys(stateByDatasource.value)) + if (datasourceId === '') { + cachedKeys.add('') + await Promise.all(Array.from(cachedKeys).map((key) => ensureLoaded(key, true))) + return + } + const toRefresh = new Set([datasourceId]) + if (cachedKeys.has('')) toRefresh.add('') + await Promise.all(Array.from(toRefresh).map((key) => ensureLoaded(key, true))) + } + + const findById = (id: string): RedisProtobufSchema | null => { + for (const list of Object.values(schemasByDatasource.value)) { + const found = list.find((item) => item.id === id) + if (found) return found + } + return null + } + + const reset = () => { + schemasByDatasource.value = {} + stateByDatasource.value = {} + errorByDatasource.value = {} + inflight.clear() + } + + const isLoading = computed(() => (datasourceId: string) => stateFor(datasourceId) === 'loading') + + return { + schemasByDatasource, + stateByDatasource, + errorByDatasource, + schemasFor, + stateFor, + isLoading, + ensureLoaded, + save, + remove, + findById, + reset, + } +}) diff --git a/frontend/src/stores/updater.ts b/frontend/src/stores/updater.ts new file mode 100644 index 0000000..471c837 --- /dev/null +++ b/frontend/src/stores/updater.ts @@ -0,0 +1,124 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' + +import { api } from '@/services/api' +import type { UpdaterResult } from '@/services/api/updater' +import { useAuthStore } from '@/stores/auth' + +const emptyResult = (): UpdaterResult => ({ + current: '', + latest: '', + hasUpdate: false, + downloadUrl: '', + platformKey: '', + platformLabel: '', + releaseNotesUrl: '', + authenticated: false, + lastCheckedAt: 0, +}) + +const DISMISSED_STORAGE_KEY = 'futrix.updater.dismissedVersion' + +const safeStorage = (): Storage | null => { + try { + return typeof window !== 'undefined' ? window.localStorage : null + } catch { + return null + } +} + +const loadDismissedVersion = (): string => { + const storage = safeStorage() + if (!storage) return '' + try { + return storage.getItem(DISMISSED_STORAGE_KEY) || '' + } catch { + return '' + } +} + +const persistDismissedVersion = (version: string) => { + const storage = safeStorage() + if (!storage) return + try { + if (version) storage.setItem(DISMISSED_STORAGE_KEY, version) + else storage.removeItem(DISMISSED_STORAGE_KEY) + } catch { + // localStorage is best-effort; ignore quota / privacy-mode failures. + } +} + +export const useUpdaterStore = defineStore('updater', () => { + const result = ref(emptyResult()) + const loading = ref(false) + const error = ref('') + // Persist the version the user dismissed so we don't re-nag on restart for + // the same release. A newer `latest` automatically un-dismisses the banner. + const dismissedVersion = ref(loadDismissedVersion()) + + const dismissed = computed(() => { + const latest = result.value.latest + return Boolean(latest) && dismissedVersion.value === latest + }) + + const hasUpdate = computed(() => result.value.authenticated && result.value.hasUpdate && Boolean(result.value.latest)) + const canOpenDownload = computed(() => hasUpdate.value && Boolean(result.value.downloadUrl)) + + const check = async (): Promise => { + if (loading.value) return + loading.value = true + error.value = '' + try { + const next = await api.checkForUpdate() + result.value = next + // The Go updater treats 401 as authenticated=false (the backend session + // has been cleared by auth.GetJSON). If the Pinia auth store still + // thinks we're signed in, re-hydrate it so the UI doesn't keep a stale + // signed-in shell. + if (!next.authenticated) { + const authStore = useAuthStore() + if (authStore.isAuthenticated) { + await authStore.restore() + } + } + } catch (err) { + error.value = err instanceof Error ? err.message : String(err || '') + } finally { + loading.value = false + } + } + + const openDownload = async (): Promise => { + const url = result.value.downloadUrl || result.value.releaseNotesUrl + if (!url) return + await api.openUpdateDownload(url) + } + + const dismiss = () => { + const latest = result.value.latest + if (!latest) return + dismissedVersion.value = latest + persistDismissedVersion(latest) + } + + const reset = () => { + result.value = emptyResult() + error.value = '' + // Keep dismissedVersion in localStorage across resets so sign-out / sign-in + // doesn't re-nag for an already-dismissed release. + } + + return { + result, + loading, + error, + dismissedVersion, + dismissed, + hasUpdate, + canOpenDownload, + check, + openDownload, + dismiss, + reset, + } +}) diff --git a/frontend/src/stores/visualization.ts b/frontend/src/stores/visualization.ts new file mode 100644 index 0000000..a645e07 --- /dev/null +++ b/frontend/src/stores/visualization.ts @@ -0,0 +1,139 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export type VisualizationRenderer = 'echarts' | 'three' | (string & {}) + +export type VisualizationChartType = 'bar' | 'line' | 'pie' +export type VisualizationAggregation = 'sum' | 'avg' | 'min' | 'max' + +export interface VisualizationBuilderConfig { + chartType: VisualizationChartType + dimensionKey: string + metricKey: string + aggregation?: VisualizationAggregation +} + +export interface VisualizationState { + id: string + title?: string + renderer: VisualizationRenderer + spec: any + datasourceId?: string + database?: string + statement?: string + rowCount?: number + builder?: VisualizationBuilderConfig + createdAt: number +} + +export const useVisualizationStore = defineStore('visualization', () => { + const STORAGE_KEY = 'futrixdata.visualization.history.v1' + const HISTORY_LIMIT = 50 + + const active = ref(null) + const history = ref([]) + + const newId = () => `viz_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` + + const normalizeVisualization = (value: unknown): VisualizationState | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + const rendererRaw = record.renderer + const renderer = ( + typeof rendererRaw === 'string' + ? rendererRaw.trim() + : typeof rendererRaw === 'number' || typeof rendererRaw === 'boolean' + ? String(rendererRaw) + : '' + ) as VisualizationRenderer + const normalizedRenderer = (renderer === 'vega-lite' ? 'vega_lite' : renderer) as VisualizationRenderer + if (!normalizedRenderer) return null + const createdAt = Number(record.createdAt || Date.now()) + return { + id: String(record.id || newId()), + title: record.title != null ? String(record.title) : undefined, + renderer: normalizedRenderer, + spec: record.spec, + datasourceId: record.datasourceId != null ? String(record.datasourceId) : undefined, + database: record.database != null ? String(record.database) : undefined, + statement: record.statement != null ? String(record.statement) : undefined, + rowCount: record.rowCount != null ? Number(record.rowCount) : undefined, + builder: record.builder && typeof record.builder === 'object' + ? { + chartType: String((record.builder as any).chartType || '') as VisualizationChartType, + dimensionKey: String((record.builder as any).dimensionKey || ''), + metricKey: String((record.builder as any).metricKey || ''), + aggregation: (record.builder as any).aggregation != null + ? (String((record.builder as any).aggregation) as VisualizationAggregation) + : undefined, + } + : undefined, + createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(), + } + } + + const loadHistory = () => { + if (typeof window === 'undefined') return + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) { history.value = []; return } + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) { history.value = []; return } + const restored = parsed + .map(normalizeVisualization) + .filter((v): v is VisualizationState => Boolean(v)) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, HISTORY_LIMIT) + history.value = restored + } catch { + history.value = [] + } + } + + const persistHistory = () => { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(history.value.slice(0, HISTORY_LIMIT))) + } catch { + // Ignore quota/serialization failures. + } + } + + loadHistory() + + const setActive = (payload: Omit & { createdAt?: number; id?: string }) => { + active.value = normalizeVisualization({ + ...payload, + id: payload.id || newId(), + createdAt: Number(payload.createdAt || Date.now()), + }) + } + + const saveActive = () => { + if (!active.value) return + const current = active.value + history.value = [current, ...history.value.filter((item) => item.id !== current.id)] + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, HISTORY_LIMIT) + persistHistory() + } + + const removeFromHistory = (id: string) => { + const target = String(id || '') + if (!target) return + history.value = history.value.filter((item) => item.id !== target) + persistHistory() + if (active.value?.id === target) { + active.value = history.value[0] || null + } + } + + const clearHistory = () => { + history.value = [] + persistHistory() + } + + const clear = () => { active.value = null } + + return { active, history, setActive, saveActive, removeFromHistory, clearHistory, clear } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..c46a1f6 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; +@import "./styles/theme.css"; +@import "./styles/ui.css"; +@import "./styles/console.css"; +@import "./styles/ai.css"; +@import "./styles/ai-chat.css"; +@import "./styles/visualization.css"; + +@custom-variant dark (&:is(.dark *)); diff --git a/frontend/src/styles/ai-chat.css b/frontend/src/styles/ai-chat.css new file mode 100644 index 0000000..830511f --- /dev/null +++ b/frontend/src/styles/ai-chat.css @@ -0,0 +1,4 @@ +@import "./ai-chat/sidebar.css"; +@import "./ai-chat/composer.css"; +@import "./ai-chat/context.css"; +@import "./ai-chat/quick-prompt-layout.css"; diff --git a/frontend/src/styles/ai-chat/composer.css b/frontend/src/styles/ai-chat/composer.css new file mode 100644 index 0000000..198fcb4 --- /dev/null +++ b/frontend/src/styles/ai-chat/composer.css @@ -0,0 +1,290 @@ +.ai-composer { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} + +.ai-composer-box { + display: flex; + flex-direction: column; + gap: 8px; + border-radius: 14px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 72%, transparent); + background: color-mix(in oklab, #ffffff 84%, var(--ai-paper)); + padding: 10px 12px; + box-shadow: 0 10px 18px rgba(40, 52, 24, 0.08); + transition: border-color 0.18s ease, box-shadow 0.18s ease; +} + +.ai-composer-box:focus-within { + border-color: color-mix(in oklab, var(--ai-sage) 42%, var(--ai-divider)); + box-shadow: + 0 10px 18px rgba(40, 52, 24, 0.1), + 0 0 0 3px color-mix(in oklab, var(--ai-sage) 18%, transparent); +} + +.ai-composer-input, +.ai-composer-input-area { + width: 100%; + border: none; + background: transparent; + color: var(--ai-ink); + font-size: 14px; + line-height: 1.45; + padding: 4px 0 2px; + resize: none; + min-height: 40px; + max-height: 120px; + overflow-y: auto; +} + +.ai-composer-input::placeholder, +.ai-composer-input-area::placeholder { + color: color-mix(in oklab, var(--ai-ink-muted) 72%, transparent); +} + +.ai-composer-input:focus, +.ai-composer-input-area:focus { + outline: none; +} + +.ai-composer-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.ai-model-select { + position: relative; + min-width: 0; + max-width: calc(100% - 90px); +} + +.ai-model-trigger { + border: none; + background: transparent; + color: var(--ai-ink-muted); + padding: 2px 4px; + min-height: 32px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.01em; + appearance: none; + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 8px; + max-width: 100%; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease; +} + +.ai-model-trigger:disabled { + opacity: 0.52; + cursor: default; +} + +.ai-model-trigger:hover:not(:disabled) { + background: color-mix(in oklab, var(--ai-amber) 16%, transparent); + color: var(--ai-ink); +} + +.ai-model-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + +.ai-model-trigger-label { + max-width: 180px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.ai-model-arrow { + font-size: 10px; + color: var(--ai-ink-muted); + opacity: 0.7; + pointer-events: none; +} + +.ai-model-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + max-width: 280px; + max-height: 220px; + overflow: auto; + background: color-mix(in oklab, #ffffff 90%, var(--ai-paper)); + border: 1px solid color-mix(in oklab, var(--ai-divider) 78%, transparent); + border-radius: 12px; + padding: 6px; + box-shadow: 0 12px 24px rgba(40, 52, 24, 0.18); + z-index: 4; +} + +.ai-model-menu.ai-model-menu-up { + top: auto; + bottom: calc(100% + 8px); +} + +.ai-model-option { + width: 100%; + border: none; + background: transparent; + color: var(--ai-ink); + padding: 8px 10px; + border-radius: 10px; + font-size: 12px; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + cursor: pointer; +} + +.ai-model-option:hover, +.ai-model-option.active { + background: color-mix(in oklab, var(--ai-amber) 30%, transparent); +} + +.ai-model-option.selected { + font-weight: 700; +} + +.ai-model-option-check { + font-size: 11px; + color: var(--ai-ink-muted); +} + +.ai-composer-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ai-voice-btn, +.ai-send-circle-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-voice-btn { + background: transparent; + color: color-mix(in oklab, var(--ai-ink-muted) 85%, transparent); +} + +.ai-voice-btn svg, +.ai-send-circle-btn svg { + width: 16px; + height: 16px; +} + +.ai-voice-btn:disabled { + opacity: 0.75; + cursor: not-allowed; +} + +.ai-send-circle-btn, +.ai-send-icon { + background: color-mix(in oklab, var(--ai-amber) 34%, #ffffff); + color: color-mix(in oklab, var(--ai-sage) 78%, var(--ai-ink)); + cursor: pointer; + transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease; + box-shadow: 0 4px 9px rgba(57, 77, 32, 0.18); +} + +.ai-send-circle-btn:hover:not(:disabled), +.ai-send-icon:hover:not(:disabled) { + transform: translateY(-1px) scale(1.02); + background: color-mix(in oklab, var(--ai-amber) 48%, #ffffff); + box-shadow: 0 8px 14px rgba(57, 77, 32, 0.18); +} + +.ai-send-circle-btn:disabled, +.ai-send-icon:disabled { + background: color-mix(in oklab, var(--ai-divider) 86%, #ffffff); + color: color-mix(in oklab, var(--ai-ink-muted) 90%, transparent); + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.ai-composer-icon { + position: relative; + width: 26px; + height: 26px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--ai-ink-muted); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-composer-glyph { + width: 14px; + height: 14px; +} + +@supports ((-webkit-mask-image: url("")) or (mask-image: url(""))) { + .ai-composer-glyph { + display: none; + } + + .ai-composer-icon::before { + content: ""; + position: absolute; + width: 16px; + height: 16px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + background: linear-gradient( + 135deg, + #5ad6ff 0%, + #7df3c1 28%, + #f8d66b 55%, + #d06bff 78%, + #6ea8ff 100% + ); + -webkit-mask-image: url("data:image/svg+xml;utf8,"); + mask-image: url("data:image/svg+xml;utf8,"); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-position: center; + mask-position: center; + filter: drop-shadow(0 0 6px rgba(90, 214, 255, 0.35)); + } +} + +@media (max-width: 520px) { + .ai-composer-input, + .ai-composer-input-area { + font-size: 13px; + min-height: 36px; + } + + .ai-model-trigger-label { + max-width: 130px; + } + + .ai-model-menu { + min-width: 180px; + max-width: 220px; + } +} diff --git a/frontend/src/styles/ai-chat/context.css b/frontend/src/styles/ai-chat/context.css new file mode 100644 index 0000000..1fe8cbf --- /dev/null +++ b/frontend/src/styles/ai-chat/context.css @@ -0,0 +1,82 @@ +.ai-context-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ai-context-chip { + position: relative; + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in oklab, var(--ai-amber) 18%, var(--ai-paper)); + border: 1px solid color-mix(in oklab, var(--ai-sage) 45%, var(--ai-divider)); + font-size: 11px; + font-weight: 600; + color: var(--ai-sage); + line-height: 1.4; +} + +.ai-context-chip::after { + content: ''; + position: absolute; + inset: 1px; + border-radius: 999px; + background: linear-gradient( + 120deg, + rgba(255, 255, 255, 0.55), + rgba(255, 255, 255, 0.12) + ); + pointer-events: none; +} + +.ai-context-remove { + border: none; + background: transparent; + color: currentColor; + font-size: 12px; + margin-left: 6px; + cursor: pointer; +} + +.ai-context-dropdown { + border: 1px solid var(--ai-divider); + background: var(--ai-ivory); + border-radius: 14px; + padding: 10px; + display: grid; + gap: 8px; + max-height: 220px; + overflow: auto; + box-shadow: 0 12px 22px rgba(40, 52, 24, 0.1); +} + +.ai-context-group-title { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--ai-ink-muted); + margin-bottom: 4px; +} + +.ai-context-item { + border: 1px solid transparent; + background: color-mix(in oklab, var(--ai-paper) 78%, var(--ai-ivory)); + border-radius: 12px; + padding: 8px 10px; + font-size: 12px; + text-align: left; + cursor: pointer; + color: var(--ai-ink); + line-height: 1.3; +} + +.ai-context-item:hover { + border-color: color-mix(in oklab, var(--ai-amber) 45%, var(--ai-divider)); + background: color-mix(in oklab, var(--ai-amber) 10%, var(--ai-paper)); +} + +.ai-context-item.active { + border-color: color-mix(in oklab, var(--ai-sage) 55%, var(--ai-divider)); + background: color-mix(in oklab, var(--ai-amber) 18%, var(--ai-paper)); + box-shadow: 0 6px 12px rgba(57, 77, 32, 0.12); +} diff --git a/frontend/src/styles/ai-chat/quick-prompt-layout.css b/frontend/src/styles/ai-chat/quick-prompt-layout.css new file mode 100644 index 0000000..b0dda9e --- /dev/null +++ b/frontend/src/styles/ai-chat/quick-prompt-layout.css @@ -0,0 +1,190 @@ +.ai-quick-prompt { + position: fixed; + z-index: 1200; + width: min(360px, 70vw); + --ai-ivory: #f9faef; + --ai-paper: #f3f4e9; + --ai-divider: #c5c8ba; + --ai-ink: #1a1c16; + --ai-ink-muted: #44483d; + --ai-sage: #4c662b; + --ai-amber: #b1d18a; + --ai-danger: #c04a3d; + --ai-warning: #c89b2c; + --ai-safe: #2f7a47; + --ai-caution: #8a6b3a; + background: var(--ai-ivory); + border: 1px solid var(--ai-divider); + border-radius: 18px; + box-shadow: 0 16px 30px rgba(40, 52, 24, 0.18); + padding: 14px; +} + +.ai-quick-form { + display: grid; + gap: 10px; +} + +.ai-quick-input { + display: flex; + gap: 8px; + align-items: center; +} + +.ai-quick-icon.ai-composer-icon { + width: 22px; + height: 22px; + flex: 0 0 auto; + border-radius: 999px; + background: color-mix(in oklab, var(--ai-paper) 70%, transparent); + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); +} + +.ai-quick-input input { + flex: 1; + border: 1px solid var(--ai-divider); + border-radius: 999px; + background: var(--ai-paper); + color: var(--ai-ink); + padding: 9px 14px; + font-size: 12px; +} + +.ai-quick-input input:focus { + outline: none; + border-color: var(--ai-sage); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--ai-sage) 25%, transparent); +} + +.ai-provider-pill { + font-size: 9px; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--ai-sage) 40%, var(--ai-divider)); + background: color-mix(in oklab, var(--ai-amber) 18%, var(--ai-paper)); + color: var(--ai-sage); + align-self: flex-start; +} + +.app-shell-grid { + --ai-width: clamp(280px, 28vw, 420px); + --nav-width: clamp(178px, 14vw, 220px); + display: grid; + grid-template-columns: var(--nav-width) minmax(0, 1fr) var(--ai-width); + height: 100%; +} + +.app-shell-grid.ai-collapsed { + --ai-width: 0px; +} + +.app-shell-grid.ai-collapsed .app-ai { + opacity: 0; + pointer-events: none; + padding: 0; +} + +.app-nav { + grid-column: 1; + min-height: 100%; +} + +.app-main { + grid-column: 2; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.app-content { + flex: 1; + overflow: auto; + padding: 16px 24px; +} + +.app-ai { + grid-column: 3; + min-width: 0; + padding: 16px; +} + +.ai-sidebar { + transition: opacity 0.2s ease, transform 0.2s ease; +} + +@media (max-width: 980px) { + .app-shell-grid { + --ai-width: clamp(200px, 27vw, 260px); + --nav-width: clamp(170px, 18vw, 190px); + } + + .app-content { + padding: 14px 18px; + } + + .app-ai { + padding: 12px; + } +} + +@media (max-width: 840px) { + .app-shell-grid { + --ai-width: clamp(160px, 23vw, 196px); + --nav-width: clamp(88px, 11vw, 112px); + } + + .app-nav-panel { + align-items: center; + padding-left: 10px; + padding-right: 10px; + } + + .app-nav-link { + width: 44px; + justify-content: center; + gap: 0; + padding-left: 0; + padding-right: 0; + } + + .app-nav-label { + display: none; + } + + .app-nav-footer { + justify-content: center; + gap: 8px; + } + + .app-content { + padding: 10px 12px; + } + + .app-ai { + padding: 10px; + } +} + +@media (max-width: 760px) { + .app-shell-grid { + grid-template-columns: var(--nav-width) minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .app-ai { + grid-column: 2; + grid-row: 3; + border-left: none; + border-top: 1px solid var(--edge); + padding: 12px 12px 16px; + } + + .ai-sidebar { + width: 100%; + box-shadow: none; + border-left: none; + } +} diff --git a/frontend/src/styles/ai-chat/sidebar.css b/frontend/src/styles/ai-chat/sidebar.css new file mode 100644 index 0000000..fa2db29 --- /dev/null +++ b/frontend/src/styles/ai-chat/sidebar.css @@ -0,0 +1,794 @@ +/* AI Chat Sidebar */ +.ai-sidebar { + --ai-rail: 56px; + --ai-ivory: #f9faef; + --ai-paper: #f3f4e9; + --ai-divider: #c5c8ba; + --ai-ink: #1a1c16; + --ai-ink-muted: #44483d; + --ai-sage: #4c662b; + --ai-amber: #b1d18a; + --ai-danger: #c04a3d; + --ai-warning: #c89b2c; + --ai-safe: #2f7a47; + --ai-caution: #8a6b3a; + width: 100%; + min-width: 0; + max-width: 100%; + height: 100%; + color: var(--ai-ink); + background: linear-gradient( + 165deg, + var(--ai-ivory) 0%, + var(--ai-paper) 62%, + color-mix(in oklab, var(--ai-paper) 85%, var(--ai-amber)) 100% + ); + border: 1px solid var(--ai-divider); + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px 16px; + padding-bottom: 48px; + border-radius: 28px; + overflow: hidden; + box-shadow: -12px 0 28px rgba(40, 52, 24, 0.12); +} + +@media (max-width: 1100px) { + .ai-sidebar { + padding-bottom: 160px; + } +} + +.ai-sidebar-header { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding-bottom: 10px; + border-bottom: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); +} + +.ai-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.ai-title-group { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.ai-composer-icon.ai-header-icon { + width: 28px; + height: 28px; + border-radius: 12px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 70%, transparent); + background: color-mix(in oklab, var(--ai-ivory) 85%, transparent); + color: var(--ai-ink-muted); +} + +.ai-title-stack { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.ai-title { + font-weight: 700; + letter-spacing: 0.1px; + text-transform: none; + font-size: 13px; + line-height: 1.1; + color: var(--ai-ink); +} + +.ai-title-sub { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--ai-ink-muted); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-icon-btn { + width: 32px; + min-width: 32px; + height: 32px; + min-height: 32px; + flex: 0 0 32px; + border-radius: 12px; + border: 1px solid var(--ai-divider); + background: color-mix(in oklab, var(--ai-paper) 80%, var(--ai-ivory)); + color: var(--ai-ink); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.ai-icon-glyph { + width: 16px; + height: 16px; +} + +.ai-icon-btn:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--ai-sage) 35%, var(--ai-divider)); + box-shadow: 0 6px 12px rgba(57, 77, 32, 0.12); +} + +.ai-history-strip { + border-radius: 12px; + border: none; + background: transparent; + padding: 0; + overflow: visible; +} + +.ai-history-scroll { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; +} + +.ai-history-tab { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 12px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 70%, transparent); + background: color-mix(in oklab, var(--ai-paper) 80%, var(--ai-ivory)); + padding: 4px 8px; + transition: transform 0.15s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.ai-history-tab.active { + border-color: color-mix(in oklab, var(--ai-sage) 60%, var(--ai-divider)); + background: color-mix(in oklab, var(--ai-amber) 20%, var(--ai-paper)); + box-shadow: 0 6px 12px rgba(57, 77, 32, 0.12); +} + +.ai-history-tab:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--ai-amber) 28%, var(--ai-divider)); +} + +.ai-history-main { + background: transparent; + border: none; + padding: 0 2px; + min-height: 32px; + cursor: pointer; + color: inherit; + display: inline-flex; + align-items: center; + border-radius: 10px; +} + +.ai-history-title { + font-size: 11px; + font-weight: 600; + color: var(--ai-ink); + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-history-delete { + border: none; + background: transparent; + color: var(--ai-ink-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; +} + +.ai-history-delete:hover { + color: var(--ai-sage); +} + +.ai-sidebar-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 14px; + overflow: hidden; +} + +.ai-chat-stream { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 12px; + padding-right: 4px; +} + +.ai-message { + max-width: 100%; + min-width: 0; + padding: 11px 13px; + border-radius: 16px; + font-size: 12px; + line-height: 1.55; + background: color-mix(in oklab, var(--ai-paper) 78%, var(--ai-ivory)); + color: var(--ai-ink); +} + +.ai-markdown { + word-break: break-word; +} + +.ai-markdown :where(h1, h2, h3) { + margin: 0.6em 0 0.25em; + line-height: 1.25; +} + +.ai-markdown :where(h1) { + font-size: 1.1em; +} + +.ai-markdown :where(h2) { + font-size: 1.05em; +} + +.ai-markdown :where(h3) { + font-size: 1em; +} + +.ai-markdown :where(p) { + margin: 0.25em 0; +} + +.ai-markdown :where(ul, ol) { + margin: 0.35em 0 0.35em 1.2em; + padding: 0; +} + +.ai-markdown :where(code) { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.92em; + padding: 0.12em 0.3em; + border-radius: 8px; + background: color-mix(in oklab, var(--ai-divider) 20%, transparent); +} + +.ai-markdown :where(pre) { + margin: 0.6em 0; + padding: 10px 12px; + border-radius: 14px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + background: color-mix(in oklab, var(--ai-ink) 92%, var(--ai-paper)); + color: #f6f7f2; + border: 1px solid color-mix(in oklab, var(--ai-divider) 30%, transparent); +} + +.ai-markdown :where(pre code) { + background: transparent; + padding: 0; + color: inherit; +} + +.ai-markdown :where(blockquote) { + margin: 0.6em 0; + padding: 0.4em 0.8em; + border-left: 3px solid color-mix(in oklab, var(--ai-sage) 55%, var(--ai-divider)); + color: color-mix(in oklab, var(--ai-ink) 78%, var(--ai-ink-muted)); + background: color-mix(in oklab, var(--ai-paper) 70%, transparent); + border-radius: 12px; +} + +.ai-markdown :where(hr) { + border: 0; + height: 1px; + margin: 0.8em 0; + background: color-mix(in oklab, var(--ai-divider) 55%, transparent); +} + +.ai-markdown :where(table) { + width: 100%; + border-collapse: collapse; + margin: 0.6em 0; + font-size: 0.95em; +} + +.ai-markdown :where(th, td) { + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); + padding: 6px 8px; + text-align: left; + vertical-align: top; +} + +.ai-markdown :where(th) { + background: color-mix(in oklab, var(--ai-amber) 16%, var(--ai-paper)); + font-weight: 700; +} + +.ai-markdown :where(a) { + color: color-mix(in oklab, var(--ai-sage) 70%, #0b3d91); + text-decoration: underline; +} + +.ai-message.user { + align-self: flex-end; + background: color-mix(in oklab, var(--ai-amber) 26%, var(--ai-paper)); +} + +.ai-message.assistant { + align-self: flex-start; + background: color-mix(in oklab, var(--ai-paper) 92%, var(--ai-sage)); + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); +} + +.ai-message-implicit { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in oklab, var(--ai-divider) 50%, transparent); +} + +.ai-message-implicit-label { + margin-bottom: 4px; + font-size: 10px; + letter-spacing: 0.04em; + color: var(--ai-ink-muted); +} + +.ai-message-implicit pre { + margin: 0; + padding: 9px 10px; + border-radius: 10px; + max-height: 180px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + background: color-mix(in oklab, var(--ai-ink) 88%, var(--ai-paper)); + color: #f6f7f2; +} + +.ai-message-implicit code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 11px; +} + +.ai-plan-card { + margin-top: 10px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); + border-radius: 14px; + background: color-mix(in oklab, var(--ai-paper) 90%, var(--ai-ivory)); + padding: 10px; + display: grid; + gap: 8px; +} + +.ai-plan-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.ai-plan-title { + font-size: 12px; + font-weight: 700; + color: var(--ai-ink); +} + +.ai-plan-agent { + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ai-ink-muted); +} + +.ai-plan-summary { + font-size: 11px; + color: color-mix(in oklab, var(--ai-ink) 82%, var(--ai-ink-muted)); +} + +.ai-plan-tabs { + display: inline-flex; + gap: 6px; +} + +.ai-plan-tab { + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); + background: color-mix(in oklab, var(--ai-paper) 78%, transparent); + color: var(--ai-ink-muted); + border-radius: 999px; + font-size: 11px; + line-height: 1.2; + padding: 4px 10px; + cursor: pointer; +} + +.ai-plan-tab.active { + color: var(--ai-ink); + background: color-mix(in oklab, var(--ai-sage) 20%, var(--ai-paper)); + border-color: color-mix(in oklab, var(--ai-sage) 55%, var(--ai-divider)); +} + +.ai-plan-markdown { + border: 1px dashed color-mix(in oklab, var(--ai-divider) 55%, transparent); + border-radius: 12px; + padding: 8px; +} + +.ai-plan-workflow { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 8px; +} + +.ai-plan-step { + border: 1px solid color-mix(in oklab, var(--ai-divider) 55%, transparent); + border-radius: 10px; + background: color-mix(in oklab, var(--ai-paper) 84%, var(--ai-ivory)); + padding: 8px; + display: grid; + gap: 4px; +} + +.ai-plan-step-top { + display: flex; + align-items: center; + gap: 8px; +} + +.ai-plan-step-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + color: color-mix(in oklab, var(--ai-paper) 95%, #000); + background: color-mix(in oklab, var(--ai-ink-muted) 68%, var(--ai-divider)); +} + +.ai-plan-step-title { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 600; + color: var(--ai-ink); +} + +.ai-plan-step-status { + flex: 0 0 auto; + font-size: 10px; + color: var(--ai-ink-muted); +} + +.ai-plan-step-description { + font-size: 11px; + color: color-mix(in oklab, var(--ai-ink) 78%, var(--ai-ink-muted)); + padding-left: 26px; +} + +.ai-plan-empty { + border: 1px dashed color-mix(in oklab, var(--ai-divider) 55%, transparent); + border-radius: 10px; + padding: 8px; + font-size: 11px; + color: var(--ai-ink-muted); +} + +/* ── Approval card ── */ +.ai-approval-card { + --ai-approval-accent: var(--ai-warning); + --_card-bg: color-mix(in oklab, var(--ai-ivory) 62%, #fff 38%); + border-radius: 12px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 55%, var(--ai-approval-accent) 10%); + border-left: 3px solid color-mix(in oklab, var(--ai-approval-accent) 70%, var(--ai-sage) 30%); + background: var(--_card-bg); + padding: 11px 12px 10px; + box-shadow: + 0 1px 2px rgba(40, 46, 28, 0.06), + 0 4px 16px rgba(40, 46, 28, 0.10); +} + +.ai-approval-tone-neutral { --ai-approval-accent: var(--ai-divider); } +.ai-approval-tone-safe { --ai-approval-accent: var(--ai-safe); } +.ai-approval-tone-warning { --ai-approval-accent: var(--ai-warning); } +.ai-approval-tone-danger { --ai-approval-accent: var(--ai-danger); } +.ai-approval-tone-caution { --ai-approval-accent: var(--ai-caution); } + +.ai-approval-header { + display: flex; + align-items: center; + gap: 6px; +} + +.ai-approval-icon { + flex: 0 0 auto; + width: 15px; + height: 15px; + color: color-mix(in oklab, var(--ai-approval-accent) 78%, var(--ai-ink) 22%); +} + +.ai-approval-icon svg { + width: 100%; + height: 100%; +} + +.ai-approval-title { + font-size: 12px; + font-weight: 700; + color: var(--ai-ink); +} + +.ai-approval-summary { + margin-top: 2px; + padding-left: 21px; + font-size: 11px; + line-height: 1.4; + color: var(--ai-ink-muted); + white-space: pre-wrap; +} + +.ai-approval-details { + margin: 8px 0 0; + padding: 7px 9px; + border-radius: 8px; + background: color-mix(in oklab, var(--ai-paper) 52%, var(--_card-bg) 48%); + border: 1px solid color-mix(in oklab, var(--ai-divider) 35%, transparent); + display: grid; + gap: 6px; +} + +.ai-approval-detail-grid { + display: grid; + gap: 4px; +} + +.ai-approval-detail-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.ai-approval-detail-label { + flex: 0 0 auto; + font-size: 10px; + font-weight: 600; + color: color-mix(in oklab, var(--ai-ink-muted) 72%, transparent); + letter-spacing: 0.02em; +} + +.ai-approval-detail-value { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 600; + color: var(--ai-ink); + text-align: right; + overflow-wrap: anywhere; +} + +.ai-risk-badge { + display: inline-flex; + align-items: center; + border-radius: 5px; + padding: 1px 6px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.ai-risk-low { + color: var(--ai-safe); + background: color-mix(in oklab, var(--ai-safe) 10%, var(--_card-bg)); + border: 1px solid color-mix(in oklab, var(--ai-safe) 22%, var(--ai-divider)); +} + +.ai-risk-medium { + color: color-mix(in oklab, var(--ai-warning) 88%, #000); + background: color-mix(in oklab, var(--ai-warning) 12%, var(--_card-bg)); + border: 1px solid color-mix(in oklab, var(--ai-warning) 26%, var(--ai-divider)); +} + +.ai-risk-high { + color: var(--ai-danger); + background: color-mix(in oklab, var(--ai-danger) 10%, var(--_card-bg)); + border: 1px solid color-mix(in oklab, var(--ai-danger) 22%, var(--ai-divider)); +} + +.ai-approval-risk-notes { + font-size: 10px; + line-height: 1.4; + color: color-mix(in oklab, var(--ai-ink-muted) 78%, transparent); + white-space: pre-wrap; +} + +.ai-trust-badge { + display: inline-flex; + align-items: center; + border-radius: 5px; + padding: 1px 6px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + border: 1px solid var(--ai-divider); + color: var(--ai-ink-muted); + background: color-mix(in oklab, var(--ai-ink) 3%, var(--_card-bg)); +} + +.ai-trust-approval { + color: color-mix(in oklab, var(--ai-ink) 72%, #000); + background: color-mix(in oklab, var(--ai-ink) 8%, var(--_card-bg)); + border-color: color-mix(in oklab, var(--ai-divider) 60%, var(--ai-ink) 12%); +} + +.ai-trust-cautious { + color: #1d4ed8; + background: color-mix(in oklab, #60a5fa 10%, var(--_card-bg)); + border-color: color-mix(in oklab, #60a5fa 26%, var(--ai-divider)); +} + +.ai-trust-trusted { + color: #b45309; + background: color-mix(in oklab, #f59e0b 10%, var(--_card-bg)); + border-color: color-mix(in oklab, #f59e0b 28%, var(--ai-divider)); +} + +.ai-trust-danger { + color: var(--ai-danger); + background: color-mix(in oklab, var(--ai-danger) 10%, var(--_card-bg)); + border-color: color-mix(in oklab, var(--ai-danger) 24%, var(--ai-divider)); +} + +.ai-approval-gate-note { + margin-top: 6px; + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in oklab, var(--ai-ink) 4%, var(--_card-bg)); + border: 1px solid color-mix(in oklab, var(--ai-divider) 55%, transparent); + font-size: 10.5px; + line-height: 1.5; + color: color-mix(in oklab, var(--ai-ink-muted) 86%, transparent); +} + +.ai-approval-code-label { + font-size: 10px; + font-weight: 600; + color: color-mix(in oklab, var(--ai-ink-muted) 72%, transparent); + letter-spacing: 0.02em; +} + +.ai-approval-code pre { + margin-top: 4px; + margin-bottom: 0; + padding: 7px 9px; + border-radius: 6px; + background: color-mix(in oklab, var(--ai-ink) 5%, var(--_card-bg)); + border: 1px solid color-mix(in oklab, var(--ai-divider) 38%, transparent); + overflow-x: auto; + overflow-y: auto; + max-height: 120px; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--ai-ink); +} + +.ai-approval-code code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.ai-approval-actions { + margin-top: 8px; + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.ai-approval-btn { + border-radius: 7px; + border: 1px solid color-mix(in oklab, var(--ai-divider) 60%, transparent); + background: var(--_card-bg); + color: var(--ai-ink-muted); + font-size: 11px; + font-weight: 600; + padding: 5px 13px; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; +} + +.ai-approval-btn:hover:enabled { + border-color: var(--ai-divider); + color: var(--ai-ink); +} + +.ai-approval-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.ai-approval-approve { + background: color-mix(in oklab, var(--ai-sage) 84%, var(--ai-amber) 16%); + border-color: transparent; + color: #fff; +} + +.ai-approval-approve:hover:enabled { + background: color-mix(in oklab, var(--ai-sage) 76%, var(--ai-amber) 24%); + box-shadow: 0 2px 8px color-mix(in oklab, var(--ai-sage) 20%, transparent); + color: #fff; +} + +.ai-approval-reject:hover:enabled { + border-color: color-mix(in oklab, var(--ai-danger) 28%, var(--ai-divider)); + color: var(--ai-danger); +} + +/* Executing state */ +.ai-approval-card.is-executing { + pointer-events: none; +} + +.ai-approval-card.is-executing .ai-approval-icon { + color: var(--ai-sage); +} + +.ai-approval-card.is-executing .ai-approval-title { + color: var(--ai-sage); +} + +.ai-approval-card.is-executing .ai-approval-details { + opacity: 0.4; +} + +.ai-approval-btn.ai-approval-approve.is-executing { + display: inline-flex; + align-items: center; + gap: 5px; + pointer-events: none; + background: color-mix(in oklab, var(--ai-sage) 65%, #777); + border-color: transparent; + color: rgba(255, 255, 255, 0.85); + opacity: 1; +} + +.ai-approval-spinner { + width: 12px; + height: 12px; + flex: 0 0 12px; + animation: ai-approval-spin 0.75s linear infinite; +} + +@keyframes ai-approval-spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/styles/ai.css b/frontend/src/styles/ai.css new file mode 100644 index 0000000..06268d2 --- /dev/null +++ b/frontend/src/styles/ai.css @@ -0,0 +1,5 @@ +@import "./ai/panel.css"; +@import "./ai/cards-actions.css"; +@import "./ai/form-panel.css"; +@import "./ai/preferences.css"; +@import "./ai/theme-overrides.css"; diff --git a/frontend/src/styles/ai/cards-actions.css b/frontend/src/styles/ai/cards-actions.css new file mode 100644 index 0000000..33bdb83 --- /dev/null +++ b/frontend/src/styles/ai/cards-actions.css @@ -0,0 +1,196 @@ + .ai-card { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + background: var(--panel-strong); + border: 1px solid var(--edge); + border-radius: 12px; + padding: var(--ai-pad-md) calc(var(--ai-pad-md) + 2px); + transition: box-shadow 0.2s, transform 0.2s; + } + + .ai-card:hover { + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); + } + + .ai-card-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + } + + .ai-card-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: var(--ai-font-md); + color: #1f2937; + } + + .ai-status-dot { + width: var(--ai-icon-size); + height: var(--ai-icon-size); + border-radius: 999px; + background: rgba(25, 23, 15, 0.25); + flex: 0 0 var(--ai-icon-size); + } + + .ai-status-dot.connected { + background: rgba(46, 160, 67, 0.85); + } + + .ai-status-dot.failed { + background: rgba(198, 40, 40, 0.9); + } + + .ai-provider-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ai-card-model { + font-size: var(--ai-font-sm); + color: var(--soft-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ai-card-status { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + min-height: 18px; + } + + .ai-card-status-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .ai-card-status .status { + margin-top: 0; + font-size: var(--ai-font-sm); + padding: var(--ai-pad-xs) var(--ai-pad-sm); + align-self: flex-start; + } + + .ai-card-status .status-detail { + margin-top: 0; + font-size: var(--ai-font-sm); + color: var(--soft-ink); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + } + + .ai-card-status .status-detail.expanded { + -webkit-line-clamp: unset; + display: block; + overflow: visible; + } + + .ai-detail-toggle { + align-self: flex-start; + padding: 2px 8px; + font-size: 11px; + line-height: 1.2; + } + + .ai-action-state { + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + font-size: 12px; + color: var(--soft-ink); + font-weight: 600; + } + + .ai-action-menu { + position: relative; + } + + .ai-action-toggle { + width: 30px; + padding: 0; + justify-content: center; + } + + .ai-action-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--panel-strong); + border: 1px solid var(--edge); + border-radius: 10px; + padding: 6px; + display: grid; + gap: 4px; + min-width: 120px; + box-shadow: var(--surface-shadow-soft); + z-index: 20; + } + + .ai-action-item { + background: transparent; + border: 1px solid transparent; + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + color: var(--ink); + text-align: left; + cursor: pointer; + } + + .ai-action-item:hover { + background: var(--panel-soft); + border-color: var(--edge); + } + + .ai-action-item.danger { + color: #b3261e; + } + + .ai-card-status.success .status-detail { + color: #1b5e20; + } + + .ai-card-status.failed .status-detail { + color: #8e1b1b; + } + + .ai-card-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + flex-shrink: 0; + align-items: flex-start; + } + + .ai-card-actions .btn { + padding: var(--ai-pad-xs) calc(var(--ai-pad-sm) + 2px); + font-size: var(--ai-font-sm); + border-radius: 8px; + } + + .ai-card-actions .btn.danger { + color: var(--accent); + } + + .ai-card-actions .btn.danger:hover { + background: rgba(208, 75, 26, 0.1); + } diff --git a/frontend/src/styles/ai/form-panel.css b/frontend/src/styles/ai/form-panel.css new file mode 100644 index 0000000..0a8a657 --- /dev/null +++ b/frontend/src/styles/ai/form-panel.css @@ -0,0 +1,210 @@ + /* AI Form Panel */ + .ai-form-panel { + width: clamp(360px, 34vw, 640px); + min-width: 320px; + max-width: min(820px, 92vw); + } + + .ai-form-panel.inline { + width: 100%; + min-width: 0; + max-width: none; + } + + .ai-settings-grid { + display: grid; + grid-template-columns: minmax(520px, 1.35fr) minmax(420px, 1fr); + gap: 24px; + align-items: start; + } + + .ai-settings-grid.single { + grid-template-columns: minmax(0, 1fr); + } + + .ai-settings-form { + min-width: 0; + } + + .ai-settings-form-shell { + max-width: 980px; + margin: 0 auto; + } + + .ai-form-core { + display: flex; + flex-direction: column; + flex: 1; + overflow-y: auto; + gap: 12px; + padding: var(--ai-pad-sm) var(--ai-pad-md) var(--ai-pad-md); + background: transparent; + min-height: 0; + } + + .ai-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .ai-field.span-2 { + grid-column: 1 / -1; + } + + .ai-form-grid label { + display: block; + font-size: var(--ai-font-sm); + font-weight: 600; + margin-bottom: 6px; + color: var(--soft-ink); + } + + .ai-form-grid input, + .ai-form-grid select { + width: 100%; + padding: var(--ai-pad-xs) var(--ai-pad-md); + padding-right: calc(var(--ai-pad-md) + 18px); + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: var(--control-radius); + font-size: var(--ai-font-md); + background: #ffffff; + } + + .ai-input-with-toggle { + position: relative; + display: flex; + align-items: center; + } + + .ai-input-with-toggle input { + padding-right: calc(var(--ai-pad-md) + 54px); + } + + .ai-visibility-toggle { + position: absolute; + right: var(--ai-pad-sm); + top: 50%; + transform: translateY(-50%); + min-width: 44px; + height: calc(var(--control-height) - 6px); + border-radius: var(--control-radius); + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0 8px; + font-size: 11px; + font-weight: 600; + } + + .ai-visibility-toggle:hover { + background: var(--panel-strong); + color: var(--ink); + box-shadow: none; + } + + .ai-visibility-toggle[data-visible="true"] { + color: var(--primary); + border-color: color-mix(in oklab, var(--primary) 55%, var(--edge)); + background: color-mix(in oklab, var(--primary) 12%, var(--panel-strong)); + box-shadow: none; + } + + .ai-visibility-toggle .eye-icon { + width: var(--ai-icon-size); + height: var(--ai-icon-size); + fill: currentColor; + } + + .ai-form-grid input:focus, + .ai-form-grid select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--glow); + } + + .ai-form-note { + margin: 0; + font-size: var(--ai-font-sm); + color: var(--soft-ink); + background: rgba(255, 255, 255, 0.8); + border: 1px dashed rgba(25, 23, 15, 0.16); + border-radius: 8px; + padding: var(--ai-pad-sm) var(--ai-pad-md); + } + + .ai-form-actions { + padding: var(--ai-pad-sm) var(--ai-pad-md) var(--ai-pad-md); + border-top: 1px solid rgba(25, 23, 15, 0.08); + display: flex; + gap: 10px; + align-items: center; + background: transparent; + } + + .ai-form-status { + margin-right: auto; + min-height: 18px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .ai-form-status .status { + margin-top: 0; + font-size: var(--ai-font-sm); + padding: var(--ai-pad-xs) var(--ai-pad-sm); + } + + .ai-form-status .status-detail { + margin-top: 0; + font-size: var(--ai-font-sm); + color: var(--soft-ink); + } + + .ai-form-status.success .status-detail { + color: #1b5e20; + } + + .ai-form-status.failed .status-detail { + color: #8e1b1b; + } + + .empty-state { + text-align: center; + color: var(--soft-ink); + padding: 18px 12px; + font-size: 12px; + border: 1px dashed rgba(25, 23, 15, 0.2); + border-radius: 10px; + background: rgba(255, 255, 255, 0.6); + } + + .empty-state.compact { + padding: 10px 12px; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + @media (max-width: 520px) { + .ai-form-grid { + grid-template-columns: 1fr; + } + } + + @media (max-width: 1100px) { + .ai-settings-grid { + grid-template-columns: 1fr; + } + + .ai-config-columns { + grid-template-columns: 1fr; + } + } diff --git a/frontend/src/styles/ai/panel.css b/frontend/src/styles/ai/panel.css new file mode 100644 index 0000000..4ae47d3 --- /dev/null +++ b/frontend/src/styles/ai/panel.css @@ -0,0 +1,258 @@ + /* AI Configuration Panel Styles */ + .ai-panel { + position: fixed; + top: 0; + right: 0; + width: clamp(360px, 32vw, 600px); + min-width: 320px; + max-width: min(760px, 92vw); + height: 100%; + background: linear-gradient(160deg, rgba(255, 252, 246, 0.96) 0%, rgba(238, 226, 210, 0.96) 100%); + border-left: 1px solid var(--edge); + box-shadow: -12px 0 28px rgba(25, 23, 15, 0.12); + z-index: 1000; + display: flex; + flex-direction: column; + transition: transform 0.3s ease; + overflow: hidden; + resize: none; + transform: translateX(100%); + container-type: inline-size; + --ai-icon-size: clamp(9px, 1.9cqw, 16px); + --ai-close-size: clamp(14px, 3cqw, 22px); + --ai-font-sm: clamp(9px, 1.6cqw, 12px); + --ai-font-md: clamp(11px, 2cqw, 14px); + --ai-font-lg: clamp(13px, 2.5cqw, 17px); + --ai-pad-xs: clamp(2px, 0.7cqw, 5px); + --ai-pad-sm: clamp(4px, 1cqw, 7px); + --ai-pad-md: clamp(7px, 1.4cqw, 10px); + } + + .ai-panel-resizer { + position: absolute; + inset: 0 auto 0 0; + width: 10px; + cursor: ew-resize; + z-index: 2; + background: linear-gradient(90deg, rgba(15, 23, 42, 0.08), rgba(255, 255, 255, 0)); + opacity: 0.6; + transition: opacity 0.2s ease; + } + + .ai-panel-resizer::after { + content: ""; + position: absolute; + left: 4px; + top: 50%; + width: 3px; + height: 56px; + border-radius: 999px; + background: rgba(25, 23, 15, 0.28); + transform: translateY(-50%); + } + + .ai-panel-resizer:hover { + opacity: 0.9; + } + + .ai-panel.resizing { + user-select: none; + } + + .ai-panel.show { + display: flex; + transform: translateX(0); + } + + .ai-panel.inline { + position: relative; + top: auto; + right: auto; + width: 100%; + min-width: 0; + max-width: none; + height: auto; + border-left: none; + box-shadow: none; + transform: none; + overflow: visible; + resize: none; + background: transparent; + } + + .ai-panel.inline .ai-panel-shell { + margin: 0; + } + + .ai-panel-shell { + margin: var(--ai-pad-sm); + border-radius: 18px; + background: var(--panel-strong); + border: 1px solid var(--edge); + box-shadow: var(--surface-shadow); + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + } + + .ai-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: var(--ai-pad-sm) var(--ai-pad-md); + border-bottom: 1px solid rgba(25, 23, 15, 0.08); + background: transparent; + } + + .ai-panel #ai-panel-close, + .ai-panel #aiconfig-form-cancel { + width: calc(var(--ai-close-size) + 12px); + height: calc(var(--ai-close-size) + 12px); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--ai-close-size); + line-height: 1; + } + + .ai-panel-title h3 { + margin: 0; + font-weight: 600; + font-size: var(--ai-font-lg); + } + + .ai-panel-subtitle { + margin: 1px 0 0; + font-size: var(--ai-font-sm); + color: var(--soft-ink); + } + + .ai-panel-core { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--ai-pad-sm) var(--ai-pad-md); + overflow: hidden; + min-height: 0; + } + + .ai-panel-core-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 2px 2px var(--ai-pad-sm); + border-bottom: 1px solid rgba(25, 23, 15, 0.08); + } + + .ai-panel-section { + font-size: var(--ai-font-sm); + font-weight: 600; + color: var(--soft-ink); + text-transform: uppercase; + letter-spacing: 0.6px; + } + + .ai-panel-core-head .btn { + width: auto; + min-width: 120px; + padding: var(--ai-pad-sm) var(--ai-pad-md); + font-size: var(--ai-font-md); + box-shadow: 0 8px 16px var(--glow); + } + + .ai-panel-list-body { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + padding: var(--ai-pad-sm) 2px var(--ai-pad-xs); + min-height: 0; + overscroll-behavior: contain; + } + + .ai-config-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: start; + } + + .ai-config-column { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + } + + .ai-config-column-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--soft-ink); + } + + .ai-group-count { + font-size: 10px; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + letter-spacing: 0.05em; + } + + .ai-settings-tabs { + display: flex; + gap: 2px; + padding: 0 4px; + margin-bottom: 12px; + border-bottom: 1px solid var(--edge); + } + + .ai-settings-tab { + position: relative; + padding: 8px 20px; + font-size: 13px; + font-weight: 600; + color: var(--soft-ink); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + letter-spacing: 0.02em; + } + + .ai-settings-tab:hover { + color: var(--ink); + } + + .ai-settings-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + .ai-embedding-note { + margin: 8px 0 0; + font-size: 11px; + color: var(--soft-ink); + background: rgba(255, 255, 255, 0.8); + border: 1px dashed rgba(25, 23, 15, 0.16); + border-radius: 8px; + padding: 8px 12px; + line-height: 1.5; + } + + .dark .ai-embedding-note { + background: rgba(18, 24, 30, 0.7); + } diff --git a/frontend/src/styles/ai/preferences.css b/frontend/src/styles/ai/preferences.css new file mode 100644 index 0000000..0d83a26 --- /dev/null +++ b/frontend/src/styles/ai/preferences.css @@ -0,0 +1,312 @@ +/* ── AI Preferences ── */ +.ai-prefs { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Header */ +.ai-prefs__header { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.ai-prefs__icon-wrap { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: color-mix(in oklab, var(--primary) 10%, var(--surface)); + border: 1px solid color-mix(in oklab, var(--primary) 15%, var(--edge)); + color: var(--primary); + flex-shrink: 0; +} + +.ai-prefs__title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--ink); +} + +.ai-prefs__desc { + margin: 4px 0 0; + font-size: 12px; + color: var(--soft-ink); +} + +/* ── Toggle Row ── */ +.ai-prefs__toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel); + cursor: pointer; + transition: border-color 0.2s ease; +} + +.ai-prefs__toggle:hover { + border-color: color-mix(in oklab, var(--primary) 25%, var(--edge)); +} + +.ai-prefs__toggle-info { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-prefs__toggle-icon { + color: var(--soft-ink); + flex-shrink: 0; +} + +.ai-prefs__toggle-label { + font-size: 13px; + font-weight: 600; + color: var(--ink); +} + +/* ── Custom Switch ── */ +.ai-prefs__switch { + position: relative; + display: inline-flex; + flex-shrink: 0; +} + +.ai-prefs__switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.ai-prefs__switch-track { + width: 40px; + height: 22px; + border-radius: 11px; + background: color-mix(in oklab, var(--ink) 18%, var(--surface)); + border: 1px solid var(--edge); + position: relative; + transition: all 0.25s ease; + cursor: pointer; +} + +.ai-prefs__switch--on .ai-prefs__switch-track { + background: var(--primary); + border-color: var(--primary); +} + +.ai-prefs__switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + transition: transform 0.25s ease; +} + +.ai-prefs__switch--on .ai-prefs__switch-thumb { + transform: translateX(18px); +} + +.ai-prefs__switch--sm .ai-prefs__switch-track { + width: 34px; + height: 18px; + border-radius: 9px; +} + +.ai-prefs__switch--sm .ai-prefs__switch-thumb { + width: 12px; + height: 12px; + top: 2px; + left: 2px; +} + +.ai-prefs__switch--sm.ai-prefs__switch--on .ai-prefs__switch-thumb { + transform: translateX(16px); +} + +/* ── Field Row (number input) ── */ +.ai-prefs__field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel); + cursor: pointer; + transition: border-color 0.2s ease; +} + +.ai-prefs__field:hover { + border-color: color-mix(in oklab, var(--primary) 25%, var(--edge)); +} + +.ai-prefs__field-info { + display: flex; + align-items: center; + gap: 10px; +} + +.ai-prefs__field-icon { + color: var(--soft-ink); + flex-shrink: 0; +} + +.ai-prefs__field-label { + font-size: 13px; + font-weight: 600; + color: var(--ink); +} + +.ai-prefs__number { + width: 72px; + height: 32px; + padding: 0 8px; + border-radius: 8px; + border: 1px solid var(--edge); + background: var(--input-bg, var(--surface)); + color: var(--ink); + font: inherit; + font-size: 13px; + font-weight: 600; + text-align: center; + transition: border-color 0.2s ease; +} + +.ai-prefs__number:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 15%, transparent); +} + +/* ── Risk Levels Section ── */ +.ai-prefs__risk-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ai-prefs__risk-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 700; + color: var(--ink); +} + +.ai-prefs__risk-header-icon { + color: var(--soft-ink); +} + +.ai-prefs__risk-hint { + margin: 0; + font-size: 11px; + color: var(--soft-ink); + line-height: 1.5; +} + +.ai-prefs__risk-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ai-prefs__risk-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel); + cursor: pointer; + transition: all 0.2s ease; +} + +.ai-prefs__risk-item:hover { + border-color: color-mix(in oklab, var(--primary) 20%, var(--edge)); +} + +.ai-prefs__risk-item--active { + border-color: color-mix(in oklab, var(--primary) 30%, var(--edge)); + background: color-mix(in oklab, var(--primary) 4%, var(--panel)); +} + +.ai-prefs__risk-item--disabled { + opacity: 0.6; + cursor: default; +} + +.ai-prefs__risk-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ai-prefs__risk-item--low .ai-prefs__risk-dot { + background: var(--success, #16a34a); +} + +.ai-prefs__risk-item--medium .ai-prefs__risk-dot { + background: #f59e0b; +} + +.ai-prefs__risk-item--high .ai-prefs__risk-dot { + background: var(--danger, #ef4444); +} + +.ai-prefs__risk-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ai-prefs__risk-name { + font-size: 13px; + font-weight: 600; + color: var(--ink); +} + +.ai-prefs__risk-desc { + font-size: 11px; + color: var(--soft-ink); + line-height: 1.4; +} + +.ai-prefs__risk-note { + margin: 0; + font-size: 11px; + color: var(--soft-ink); + opacity: 0.7; +} + +/* ── Responsive ── */ +@media (max-width: 480px) { + .ai-prefs__toggle, + .ai-prefs__field { + flex-direction: column; + align-items: stretch; + gap: 10px; + } + + .ai-prefs__number { + width: 100%; + } +} diff --git a/frontend/src/styles/ai/theme-overrides.css b/frontend/src/styles/ai/theme-overrides.css new file mode 100644 index 0000000..8828662 --- /dev/null +++ b/frontend/src/styles/ai/theme-overrides.css @@ -0,0 +1,127 @@ +.notice { + background: var(--notice-bg); + border-color: var(--notice-border); + color: var(--ink); +} + +.card, +.panel, +.entity-detail, +.result, +.mongo-item, +.history-item { + background: var(--panel); + border-color: var(--edge); +} + +.ai-panel-shell, +.ai-card { + background: var(--panel-strong); + border-color: var(--edge); +} + +.result, +.entity-detail, +.history-item { + color: var(--ink); +} + +input, +select, +textarea { + background: var(--input-bg); + color: var(--ink); + border-color: var(--edge); +} + +.btn.secondary { + color: var(--ink); + border-color: var(--edge); +} + +.btn.ghost { + color: var(--soft-ink); + border-color: var(--edge); +} + +.status.connected { + background: var(--success-bg); + border-color: var(--success); + color: var(--success); +} + +.status.failed { + background: var(--danger-bg); + border-color: var(--danger); + color: var(--danger); +} + +.statement-status.success { + border-color: rgba(74, 222, 128, 0.35); + background: rgba(74, 222, 128, 0.12); + color: var(--success); +} + +.statement-status.failed { + border-color: rgba(248, 113, 113, 0.4); + background: rgba(248, 113, 113, 0.12); + color: var(--danger); +} + +.statement-status.warning { + border-color: rgba(234, 179, 8, 0.4); + background: rgba(234, 179, 8, 0.16); + color: #b45309; +} + +.mongo-item-action { + border-color: var(--edge); + color: var(--ink); +} + +.ai-panel { + background: linear-gradient( + 160deg, + color-mix(in oklab, var(--panel-strong) 80%, transparent) 0%, + color-mix(in oklab, var(--panel) 85%, transparent) 100% + ); +} + +.ai-panel-shell { + background: var(--panel-strong); +} + +.ai-form-grid input, +.ai-form-grid select { + background: var(--input-bg); + color: var(--ink); + border-color: var(--edge); +} + +.ai-visibility-toggle { + border-color: var(--edge); +} + +.ai-card-title, +.ai-card-model { + color: var(--soft-ink); +} + +.dark .btn.secondary:hover, +.dark .btn.ghost:hover { + background: rgba(255, 255, 255, 0.06); +} + +.dark .entity-item:hover, +.dark .history-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.dark .result, +.dark .entity-detail { + background: rgba(18, 24, 30, 0.7); +} + +.dark .ai-form-note { + background: rgba(18, 24, 30, 0.7); +} diff --git a/frontend/src/styles/console.css b/frontend/src/styles/console.css new file mode 100644 index 0000000..4458d46 --- /dev/null +++ b/frontend/src/styles/console.css @@ -0,0 +1,14 @@ +@import "./console/statement-editor.css"; +@import "./console/layout-entities-tabs.css"; +@import "./console/templates-redis-mongo-menu.css"; +@import "./console/columns-toolbar-mongo.css"; +@import "./console/explain-pagination-status.css"; +@import "./console/results-history-responsive.css"; +@import "./console/sql-editor-parity.css"; +@import "./console/dynamodb-controls.css"; +@import "./console/elastic-json-tokens.css"; +@import "./console/elastic-dsl-parity.css"; +@import "./console/chroma-dsl-parity.css"; +@import "./console/elastic-results-parity.css"; +@import "./console/chroma-dsl-parity.css"; +@import "./console/chroma-results-parity.css"; diff --git a/frontend/src/styles/console/chroma-dsl-parity.css b/frontend/src/styles/console/chroma-dsl-parity.css new file mode 100644 index 0000000..2884eff --- /dev/null +++ b/frontend/src/styles/console/chroma-dsl-parity.css @@ -0,0 +1,772 @@ +/* --- Chroma DSL workspace: compact toolbar layout (mirrors ES) --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-workspace { + border-bottom: 1px solid var(--edge); + background: var(--panel-strong, var(--panel)); +} + +/* --- Header --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-head { + padding: 14px 20px 8px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-head h2 { + margin: 0; + font-size: 13px; + line-height: 1.3; + font-weight: 700; + color: var(--ink); + letter-spacing: -0.01em; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-head p { + margin: 2px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--soft-ink); +} + +/* --- Toolbar --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-toolbar { + padding: 0 20px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-left, +.console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-right { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-left { + min-width: 0; + flex: 1 1 auto; + gap: 8px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-right { + flex: 0 0 auto; + white-space: nowrap; + overflow-x: auto; + gap: 10px; +} + +/* --- Mode toggle (segmented pill) --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-mode-toggle { + display: inline-flex; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + overflow: hidden; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-mode-chip { + min-height: 32px; + padding: 0 10px; + border: 0; + border-right: 1px solid var(--edge); + background: var(--panel-strong, var(--panel)); + color: var(--soft-ink); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-mode-chip:last-child { + border-right: 0; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-mode-chip.active { + background: color-mix(in oklab, var(--primary) 12%, var(--panel-strong, var(--panel))); + color: var(--primary); + font-weight: 700; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-mode-chip:hover:not(.active) { + background: color-mix(in oklab, var(--primary) 5%, var(--panel-strong, var(--panel))); +} + +/* --- Inline input --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-inline-input { + flex: 1 1 0; + min-width: 120px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-inline-input input { + width: 100%; + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + font-size: 12px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-inline-input input::placeholder { + color: var(--soft-ink); +} + +/* Query combo: inline tag prefix inside input border */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-query-combo { + display: flex; + align-items: stretch; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + overflow: hidden; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-query-combo:focus-within { + border-color: var(--primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 20%, transparent); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-query-type-tag { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 0 10px; + border-right: 1px solid var(--edge); + background: color-mix(in srgb, var(--primary) 6%, transparent); + color: var(--primary); + font-size: 11px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + user-select: none; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-query-combo input { + flex: 1 1 0; + min-width: 80px; + min-height: 32px; + padding: 0 10px; + border: none; + border-radius: 0; + background: transparent; + color: var(--ink); + font-size: 12px; + outline: none; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-query-combo input.is-mono { + font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, monospace; + font-size: 11px; + letter-spacing: -0.02em; +} + +/* Search mode toggle (Vector / Text) */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-search-mode-toggle { + display: inline-flex; + align-items: stretch; + flex: 0 0 auto; + border-right: 1px solid var(--edge); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-search-mode-chip { + padding: 0 10px; + border: none; + background: color-mix(in srgb, var(--primary) 4%, transparent); + color: var(--soft-ink); + font-size: 11px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + cursor: pointer; + transition: background 0.15s, color 0.15s; + min-height: 32px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-search-mode-chip:first-child { + border-right: 1px solid var(--edge); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-search-mode-chip.active { + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-search-mode-chip:hover:not(.active) { + background: color-mix(in srgb, var(--primary) 7%, transparent); + color: var(--ink); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-embedding-select { + flex: 0 0 auto; + width: auto; + max-width: 180px; + min-height: 32px; + padding: 0 8px; + border: none; + border-left: 1px solid var(--edge); + border-radius: 0; + background: color-mix(in srgb, var(--primary) 4%, transparent); + color: var(--ink); + font-size: 11px; + outline: none; + cursor: pointer; + text-overflow: ellipsis; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-embedding-select:focus { + background: color-mix(in srgb, var(--primary) 8%, transparent); +} + +/* --- Limit field --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-limit-field { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-limit-field span { + font-size: 11px; + font-weight: 500; + color: var(--soft-ink); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-limit-field input { + width: 56px; + min-height: 32px; + padding: 0 6px; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + font-size: 12px; + text-align: center; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-limit-field input[type="number"][step="any"] { + width: 64px; +} + +/* --- Include chips --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-include-chips { + display: inline-flex; + gap: 4px; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-include-chip { + min-height: 28px; + padding: 0 8px; + border-radius: var(--control-radius, 8px); + border: 1px solid var(--edge); + background: var(--panel-strong, var(--panel)); + color: var(--soft-ink); + font-size: 11px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-include-chip:hover { + border-color: color-mix(in oklab, var(--primary) 30%, var(--edge)); + color: var(--ink); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-include-chip.active { + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + background: color-mix(in oklab, var(--primary) 10%, var(--panel-strong, var(--panel))); + color: var(--primary); + font-weight: 600; +} + +/* --- Live DSL toggle --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-live-toggle { + display: inline-flex; + align-items: center; + min-height: 32px; + gap: 6px; + padding: 0 4px; + font-size: 11px; + font-weight: 500; + color: var(--soft-ink); + white-space: nowrap; + cursor: pointer; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-live-icon { + width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--panel-soft, var(--panel)); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--soft-ink); + font-size: 10px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-live-toggle input { + margin: 0; + width: 16px; + height: 16px; + min-height: 16px; + accent-color: var(--primary); +} + +/* --- Filter button --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-btn { + min-height: 32px; + border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--edge)); + border-radius: var(--control-radius, 8px); + padding: 0 12px; + background: var(--panel-strong, var(--panel)); + color: var(--primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; + position: relative; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-btn:hover { + background: color-mix(in oklab, var(--primary) 8%, var(--panel-strong, var(--panel))); + border-color: color-mix(in oklab, var(--primary) 45%, var(--edge)); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-dot { + position: absolute; + top: 4px; + right: 4px; + width: 6px; + height: 6px; + border-radius: 999px; + background: var(--primary); +} + +/* --- Reset button --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-reset-btn { + min-height: 32px; + display: inline-flex; + align-items: center; + border: 0; + background: transparent; + color: var(--soft-ink); + font-size: 11px; + font-weight: 500; + text-decoration: underline dotted; + text-underline-offset: 3px; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-reset-btn:hover { + color: var(--ink); +} + +/* --- Status hint --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-status-hint { + font-size: 11px; + color: var(--danger, #dc2626); + font-weight: 500; + white-space: nowrap; + flex: 0 0 auto; +} + +/* --- Run button --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-run-btn { + height: 32px; + border: 1px solid var(--primary); + border-radius: 6px; + padding: 0 14px; + background: var(--primary); + color: var(--primary-foreground, #fff); + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 1px 2px color-mix(in oklab, var(--primary) 30%, transparent); + white-space: nowrap; + flex: 0 0 auto; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-run-btn:hover:not(:disabled) { + background: color-mix(in oklab, var(--primary) 88%, white 12%); + border-color: color-mix(in oklab, var(--primary) 80%, white 20%); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-run-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +/* --- Filter editor (advanced row) --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-editor { + padding: 0 20px 10px; + display: flex; + align-items: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-field { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1 1 0; + min-width: 160px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-field .chroma-dsl-filter-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--soft-ink); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-field input { + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + font-size: 12px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-field input::placeholder { + color: var(--soft-ink); + font-family: inherit; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-hint { + display: block; + padding: 2px 0; + font-size: 10px !important; + font-weight: 400 !important; + text-transform: none !important; + letter-spacing: 0 !important; + color: var(--soft-ink); + opacity: 0.7; + line-height: 1.3; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + -webkit-user-select: text; + user-select: text; + cursor: text; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-hint.is-error { + color: var(--danger, #dc2626); + opacity: 1; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-field input.is-invalid { + border-color: var(--danger, #dc2626); +} + +/* --- Filter extras (embeddings toggle etc.) --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-filter-extras { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; + align-self: flex-end; + padding-bottom: 2px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-extra-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 500; + color: var(--soft-ink); + white-space: nowrap; + cursor: pointer; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-extra-toggle input { + width: 14px; + height: 14px; + min-height: 14px; + margin: 0; + accent-color: var(--primary); +} + +/* --- Live DSL drawer --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer { + border-top: 1px solid var(--edge); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-head { + padding: 10px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-status { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-status h4 { + margin: 0; + font-size: 12px; + font-weight: 700; + color: var(--ink); + letter-spacing: -0.01em; +} + +.console-panel--statement.sql-editor-parity .chroma-sync-pill { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 12%, transparent); + color: var(--primary); +} + +.console-panel--statement.sql-editor-parity .chroma-json-validity { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 7px; + border-radius: 999px; +} + +.console-panel--statement.sql-editor-parity .chroma-json-validity.ok { + background: color-mix(in oklab, #22c55e 12%, transparent); + color: #16a34a; +} + +.console-panel--statement.sql-editor-parity .chroma-json-validity.error { + background: color-mix(in oklab, #ef4444 12%, transparent); + color: #dc2626; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-actions { + display: inline-flex; + gap: 6px; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-actions button { + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + font-size: 11px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-drawer-actions button:hover { + background: color-mix(in oklab, var(--primary) 6%, var(--panel)); +} + +/* --- DSL editor shell (syntax-highlighted + line numbers, mirrors ES) --- */ + +.console-panel--statement.sql-editor-parity .chroma-dsl-editor-shell { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + overflow: hidden; + background: var(--panel-soft, #f8fafc); + border-top: 1px solid var(--edge); +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-line-numbers { + width: 40px; + overflow: hidden; + min-height: 0; + background: var(--panel-soft, #f1f5f9); + border-right: 1px solid var(--edge); + user-select: none; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-line-numbers-inner { + display: flex; + flex-direction: column; + padding: 14px 0; + will-change: transform; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-line-number { + display: block; + height: 18px; + line-height: 18px; + text-align: right; + padding: 0 8px 0 4px; + font-size: 11px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + color: var(--soft-ink); + opacity: 0.55; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-editor-pane { + position: relative; + overflow: hidden; + min-height: 0; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-editor-highlight { + position: absolute; + top: 0; + left: 0; + padding: 14px 16px; + margin: 0; + white-space: pre; + font-size: 12px; + line-height: 18px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + color: var(--ink); + pointer-events: none; + will-change: transform; + min-width: 100%; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-editor-scrollbar-mask { + position: absolute; + bottom: 0; + right: 0; + width: 14px; + height: 14px; + background: var(--panel-soft, #f8fafc); + pointer-events: none; + z-index: 1; +} + +.console-panel--statement.sql-editor-parity .chroma-dsl-editor { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + padding: 14px 16px; + margin: 0; + border: 0; + outline: none; + resize: none; + overflow: auto; + background: transparent; + color: transparent; + caret-color: var(--ink); + font-size: 12px; + line-height: 18px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + white-space: pre; + z-index: 1; +} + +/* --- Structural overrides (mirror ES chroma-stitch) --- */ + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + grid-template-rows: auto minmax(0, 1fr); +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .console-editor-results-splitter { + display: none; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-statement-panel--chroma-stitch { + grid-template-rows: auto minmax(0, 1fr); + min-height: max-content; + background: var(--panel-soft, #f5f7fa); +} + +.console-shell.sql-editor-parity.chroma-stitch .console-statement-panel--chroma-stitch .statement-shell--sql-editor { + max-height: 0; + min-height: 0; + overflow: hidden; + border: 0; + padding: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-statement-panel--chroma-stitch .statement-monaco { + height: 0; + min-height: 0; + opacity: 0; + pointer-events: none; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .chroma-dsl-workspace, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .chroma-dsl-head, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .chroma-dsl-toolbar, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .chroma-dsl-filter-editor, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .chroma-dsl-drawer { + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +/* --- Responsive --- */ + +@media (max-width: 980px) { + .console-panel--statement.sql-editor-parity .chroma-dsl-toolbar { + flex-direction: column; + align-items: stretch; + } + + .console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-left, + .console-panel--statement.sql-editor-parity .chroma-dsl-toolbar-right { + flex-wrap: wrap; + } +} + +@media (max-width: 840px) { + .console-panel--statement.sql-editor-parity .chroma-dsl-filter-editor { + flex-direction: column; + } + + .console-shell.sql-editor-parity.chroma-stitch .console-statement-panel--chroma-stitch { + min-height: 0; + } +} + +/* --- Dark mode --- */ + +.dark .console-panel--statement.sql-editor-parity .chroma-json-validity.ok { + background: color-mix(in oklab, #22c55e 14%, transparent); + color: #86efac; +} + +.dark .console-panel--statement.sql-editor-parity .chroma-json-validity.error { + background: color-mix(in oklab, #ef4444 14%, transparent); + color: #fca5a5; +} diff --git a/frontend/src/styles/console/chroma-results-parity.css b/frontend/src/styles/console/chroma-results-parity.css new file mode 100644 index 0000000..585ca69 --- /dev/null +++ b/frontend/src/styles/console/chroma-results-parity.css @@ -0,0 +1,716 @@ +.console-results-content--sql-editor .result.result--chroma-workspace { + display: flex; + flex-direction: column; + border-top: 0; + background: transparent; + box-shadow: none; + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-results-workspace { + --chroma-surface: var(--panel-strong); + --chroma-surface-soft: color-mix(in oklab, var(--panel-soft) 82%, transparent); + --chroma-surface-muted: color-mix(in oklab, var(--panel) 88%, transparent); + --chroma-border: color-mix(in oklab, var(--edge) 78%, var(--primary) 22%); + --chroma-border-soft: color-mix(in oklab, var(--edge) 88%, transparent); + --chroma-text: color-mix(in oklab, var(--ink) 92%, var(--primary) 8%); + --chroma-strong: var(--ink); + --chroma-muted: color-mix(in oklab, var(--soft-ink) 84%, var(--primary) 16%); + --chroma-accent: color-mix(in oklab, var(--primary) 76%, var(--ink) 24%); + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 12px 0; + box-sizing: border-box; + background: transparent; +} + +.console-results-content--sql-editor .chroma-results-pane { + flex: 1 1 auto; + min-height: 0; + height: auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border: 1px solid var(--chroma-border-soft); + border-radius: 10px; + background: var(--chroma-surface); + box-shadow: var(--surface-shadow-soft); + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-results-ops { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 0; + border-radius: 0; + background: var(--chroma-surface); + box-shadow: none; + padding: 10px 12px; + overflow-x: auto; + border-bottom: 1px solid var(--chroma-border-soft); +} + +.console-results-content--sql-editor .chroma-results-ops-summary { + min-width: 0; +} + +.console-results-content--sql-editor .chroma-results-ops-summary h3 { + margin: 0; + color: var(--chroma-strong); + font-size: 14px; + line-height: 1.2; + font-weight: 700; +} + +.console-results-content--sql-editor .chroma-results-ops-meta { + margin-left: 8px; + color: var(--chroma-muted); + font-size: 12px; + font-weight: 600; +} + +.console-results-content--sql-editor .chroma-results-ops-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + white-space: nowrap; + overflow-x: auto; +} + +.console-results-content--sql-editor .chroma-results-view-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + border: 1px solid var(--chroma-border-soft); + border-radius: 7px; + background: var(--chroma-surface-soft); + padding: 2px; +} + +.console-results-content--sql-editor .chroma-ops-button { + flex: 0 0 auto; + min-height: 32px; + border-radius: 6px; + border: 1px solid var(--chroma-border-soft); + background: var(--chroma-surface); + color: var(--chroma-muted); + font-size: 11px; + font-weight: 600; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.console-results-content--sql-editor .chroma-results-view-toggle .chroma-ops-button { + border-color: transparent; + background: transparent; + min-height: 32px; + padding: 0 9px; +} + +.console-results-content--sql-editor .chroma-results-view-toggle .chroma-ops-button.active { + border-color: var(--chroma-border-soft); + background: var(--chroma-surface); + color: var(--chroma-accent); +} + +.console-results-content--sql-editor .chroma-ops-button--icon { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.console-results-content--sql-editor .chroma-ops-icon { + width: 15px; + height: 15px; + stroke-width: 2; + transition: transform 0.15s ease; +} + +.console-results-content--sql-editor .chroma-ops-icon.is-open { + transform: rotate(180deg); +} + +.console-results-content--sql-editor .chroma-ops-button:hover { + border-color: var(--chroma-border); + color: var(--chroma-accent); + background: var(--chroma-surface-soft); +} + +.console-results-content--sql-editor .chroma-results-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-results-list-wrap { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-results-list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + overflow: hidden; + background: transparent; + box-shadow: none; +} + +.console-results-content--sql-editor .chroma-results-list-wrap > .meta { + margin: 16px; + border: 1px dashed var(--chroma-border-soft); + border-radius: 8px; + background: var(--chroma-surface-soft); + color: var(--chroma-muted); + padding: 16px; + text-align: center; +} + +.console-results-content--sql-editor .chroma-results-table-head-wrap { + flex: 0 0 auto; + overflow: hidden; + border-bottom: 1px solid var(--chroma-border-soft); + background: var(--chroma-surface); + box-sizing: border-box; + padding-right: var(--chroma-results-body-scrollbar-width, 0px); +} + +.console-results-content--sql-editor .chroma-results-table-wrap { + flex: 1 1 auto; + min-height: 0; + height: auto; + overflow: auto; + border-top: 0; +} + +.console-results-content--sql-editor .chroma-results-table { + width: 100%; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: var(--chroma-text); +} + +.console-results-content--sql-editor .chroma-results-table thead th { + z-index: 3; + border-bottom: 0; + background: color-mix(in oklab, var(--chroma-surface) 94%, var(--chroma-surface-soft) 6%); + color: var(--chroma-strong); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-align: left; + padding: 9px 14px; + text-transform: uppercase; + min-width: 64px; +} + +.console-results-content--sql-editor .chroma-results-table th, +.console-results-content--sql-editor .chroma-results-table td { + border-left: 0; + border-right: 0; +} + +.console-results-content--sql-editor .chroma-results-table th.chroma-col-toggle, +.console-results-content--sql-editor .chroma-results-table td.chroma-cell-toggle { + width: 44px; + min-width: 44px; + max-width: 44px; + text-align: center; + padding: 0; +} + +.console-results-content--sql-editor .chroma-results-table th.chroma-col-toggle { + background: var(--chroma-surface); + border-bottom-color: transparent; +} + +.console-results-content--sql-editor .chroma-results-table td.chroma-cell-toggle { + background: transparent; +} + +.console-results-content--sql-editor .chroma-results-row td { + padding: 8px 14px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + vertical-align: middle; +} + +.console-results-content--sql-editor .chroma-results-row td:not(.chroma-cell-toggle) { + border-top: 1px solid var(--chroma-border-soft); + min-width: 64px; + max-width: 320px; + cursor: pointer; +} + +.console-results-content--sql-editor .chroma-results-row:first-child td:not(.chroma-cell-toggle) { + border-top: 0; +} + +.console-results-content--sql-editor .chroma-results-row:nth-child(even) td:not(.chroma-cell-toggle) { + background: color-mix(in oklab, var(--chroma-surface-soft) 50%, transparent); +} + +.console-results-content--sql-editor .chroma-results-row:hover td:not(.chroma-cell-toggle) { + background: color-mix(in oklab, var(--chroma-surface-soft) 82%, var(--primary) 18%); +} + +.console-results-content--sql-editor .chroma-results-row.is-open td:not(.chroma-cell-toggle) { + border-top-color: color-mix(in oklab, var(--chroma-border-soft) 70%, var(--primary) 30%); + background: color-mix(in oklab, var(--chroma-surface-soft) 76%, var(--primary) 24%); +} + +.console-results-content--sql-editor .chroma-row-toggle { + width: 32px; + height: 32px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--chroma-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.console-results-content--sql-editor .chroma-row-toggle:hover { + border-color: var(--chroma-border-soft); + background: var(--chroma-surface-soft); + color: var(--chroma-accent); +} + +.console-results-content--sql-editor .chroma-result-chevron { + width: 14px; + height: 14px; + stroke-width: 2.2; + transform: rotate(0deg); + transition: transform 0.15s ease; +} + +.console-results-content--sql-editor .chroma-results-row.is-open .chroma-result-chevron { + transform: rotate(90deg); +} + +.console-results-content--sql-editor .chroma-result-cell { + min-width: 0; +} + +.console-results-content--sql-editor .chroma-results-table thead th.chroma-result-head--width-xs, +.console-results-content--sql-editor .chroma-result-cell--width-xs { + width: 7%; +} + +.console-results-content--sql-editor .chroma-results-table thead th.chroma-result-head--width-sm, +.console-results-content--sql-editor .chroma-result-cell--width-sm { + width: 15%; +} + +.console-results-content--sql-editor .chroma-results-table thead th.chroma-result-head--width-md, +.console-results-content--sql-editor .chroma-result-cell--width-md { + width: 20%; +} + +.console-results-content--sql-editor .chroma-results-table thead th.chroma-result-head--width-lg, +.console-results-content--sql-editor .chroma-result-cell--width-lg { + width: 30%; +} + +.console-results-content--sql-editor .chroma-result-cell .value { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--chroma-strong); +} + +.console-results-content--sql-editor .chroma-value-pill { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 24px; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid var(--chroma-border-soft); + font-size: 11px; + line-height: 1.35; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .chroma-value-pill--identifier { + color: color-mix(in oklab, var(--chroma-text) 84%, #94a3b8 16%); + background: color-mix(in oklab, #94a3b8 10%, var(--chroma-surface)); + border-color: color-mix(in oklab, #94a3b8 28%, var(--edge)); + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-results-content--sql-editor .chroma-value-pill--number { + color: color-mix(in oklab, #3b82f6 82%, var(--chroma-text)); + background: color-mix(in oklab, #3b82f6 12%, var(--chroma-surface)); + border-color: color-mix(in oklab, #3b82f6 30%, var(--edge)); +} + +.console-results-content--sql-editor .chroma-value-pill--boolean { + color: color-mix(in oklab, #10b981 82%, var(--chroma-text)); + background: color-mix(in oklab, #10b981 12%, var(--chroma-surface)); + border-color: color-mix(in oklab, #10b981 30%, var(--edge)); +} + +.console-results-content--sql-editor .chroma-value-pill--array { + color: color-mix(in oklab, #8b5cf6 74%, var(--chroma-text)); + background: color-mix(in oklab, #8b5cf6 10%, var(--chroma-surface)); + border-color: color-mix(in oklab, #8b5cf6 28%, var(--edge)); +} + +.console-results-content--sql-editor .chroma-value-pill--object { + color: color-mix(in oklab, #06b6d4 74%, var(--chroma-text)); + background: color-mix(in oklab, #06b6d4 10%, var(--chroma-surface)); + border-color: color-mix(in oklab, #06b6d4 28%, var(--edge)); +} + +.console-results-content--sql-editor .chroma-results-row-detail td { + padding: 0; + border-top: 0; + background: color-mix(in oklab, var(--chroma-surface-soft) 80%, transparent); + /* Prevent long pre content from expanding the table beyond 100% */ + width: 0; + min-width: 100%; + max-width: 0; + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-result-card-body { + border-top: 1px solid var(--chroma-border-soft); + background: transparent; + padding: 0; + min-width: 0; + overflow: hidden; +} + +.console-results-content--sql-editor .chroma-result-body-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px 8px; +} + +.console-results-content--sql-editor .chroma-result-card-body h5 { + margin: 0; + color: var(--chroma-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.console-results-content--sql-editor .chroma-result-body-actions { + display: inline-flex; + align-items: center; + gap: 10px; + overflow-x: auto; +} + +.console-results-content--sql-editor .chroma-result-body-actions button { + border: 0; + padding: 0; + background: transparent; + color: var(--chroma-accent); + font-size: 11px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.console-results-content--sql-editor .chroma-result-body-actions button:hover { + color: color-mix(in oklab, var(--primary) 84%, #ffffff 16%); +} + +.console-results-content--sql-editor .chroma-cell-context-menu { + position: fixed; + z-index: 12; + min-width: 168px; + padding: 6px; + border: 1px solid var(--chroma-border-soft); + border-radius: 10px; + background: color-mix(in oklab, var(--chroma-surface) 96%, #05070d 4%); + box-shadow: 0 18px 30px rgba(15, 23, 42, 0.2); +} + +.console-results-content--sql-editor .chroma-cell-context-menu button { + width: 100%; + min-height: 32px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--chroma-text); + font-size: 11px; + font-weight: 600; + text-align: left; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; +} + +.console-results-content--sql-editor .chroma-cell-context-menu button:hover { + background: var(--chroma-surface-soft); + color: var(--chroma-accent); +} + +.console-results-content--sql-editor .chroma-result-metadata { + margin: 0 12px 8px; + border: 1px solid var(--chroma-border-soft); + border-radius: 8px; + background: var(--chroma-surface); + padding: 8px 10px; + display: grid; + gap: 4px; +} + +.console-results-content--sql-editor .chroma-result-metadata .meta-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.console-results-content--sql-editor .chroma-result-metadata .meta-key { + color: var(--chroma-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + flex: 0 0 auto; +} + +.console-results-content--sql-editor .chroma-result-metadata .meta-value { + color: var(--chroma-text); + font-size: 11px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + min-width: 0; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .chroma-result-card-body pre { + margin: 0; + min-height: 200px; + max-height: 340px; + overflow: auto; + border-top: 1px solid var(--chroma-border-soft); + background: color-mix(in oklab, var(--chroma-surface) 92%, var(--chroma-surface-soft) 8%); + color: var(--chroma-text); + padding: 12px; + font-size: 12px; + line-height: 1.6; + border-radius: 0 0 12px 12px; +} + +.console-results-content--sql-editor .chroma-results-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--chroma-border-soft); + background: color-mix(in oklab, var(--chroma-surface) 94%, var(--chroma-surface-soft) 6%); + color: var(--chroma-strong); + font-size: 12px; + font-weight: 500; +} + +.console-results-content--sql-editor .chroma-results-footer-range { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .chroma-results-footer-pager { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + white-space: nowrap; + overflow-x: auto; +} + +.console-results-content--sql-editor .chroma-results-footer-pager button { + height: 32px; + min-width: 32px; + border-radius: 7px; + border: 1px solid var(--chroma-border-soft); + background: var(--chroma-surface); + color: var(--chroma-muted); + font-size: 12px; + font-weight: 700; + padding: 0 8px; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.console-results-content--sql-editor .chroma-results-footer-pager button:hover:not(:disabled) { + border-color: var(--chroma-border); + color: var(--chroma-accent); + background: var(--chroma-surface-soft); +} + +.console-results-content--sql-editor .chroma-results-footer-pager button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.console-results-content--sql-editor .chroma-results-footer-pager .chroma-page-number.active { + border-color: color-mix(in oklab, var(--primary) 52%, var(--chroma-border-soft)); + background: color-mix(in oklab, var(--primary) 12%, var(--chroma-surface)); + color: var(--chroma-accent); +} + +.console-results-content--sql-editor .chroma-results-footer-pager .chroma-page-number:disabled { + min-width: 18px; + padding: 0 2px; + border-color: transparent; + background: transparent; + opacity: 0.85; + cursor: default; +} + +.console-results-content--sql-editor .chroma-results-raw-view { + min-height: 0; + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 0; + border-radius: 0; + overflow: hidden; + background: transparent; + box-shadow: none; +} + +.console-results-content--sql-editor .chroma-results-raw-toolbar { + padding: 8px 10px; + color: var(--chroma-muted); + border-bottom: 1px solid var(--chroma-border-soft); + background: var(--chroma-surface-muted); + font-size: 11px; + font-weight: 600; +} + +.console-results-content--sql-editor .chroma-results-raw-view pre { + margin: 0; + padding: 12px; + overflow: auto; + background: color-mix(in oklab, var(--panel) 84%, #0b1220 16%); + color: var(--chroma-text); + font-size: 12px; + line-height: 1.65; +} + +.dark .console-results-content--sql-editor .chroma-results-workspace { + --chroma-surface: color-mix(in oklab, var(--panel-strong) 90%, #06090f 10%); + --chroma-surface-soft: color-mix(in oklab, var(--panel-soft) 82%, #05070d 18%); + --chroma-surface-muted: color-mix(in oklab, var(--panel) 82%, #05070d 18%); + --chroma-border: color-mix(in oklab, var(--edge) 70%, var(--primary) 30%); + --chroma-border-soft: color-mix(in oklab, var(--edge) 90%, #10151f 10%); +} + +.dark .console-results-content--sql-editor .chroma-results-pane { + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); +} + +.dark .console-results-content--sql-editor .chroma-results-row:hover td:not(.chroma-cell-toggle) { + background: color-mix(in oklab, var(--panel-soft) 70%, var(--primary) 30%); +} + +@media (max-width: 980px) { + .console-results-content--sql-editor .chroma-results-workspace { + padding: 8px 0; + } + + .console-results-content--sql-editor .chroma-results-ops { + flex-direction: column; + align-items: stretch; + padding: 8px; + } + + .console-results-content--sql-editor .chroma-results-ops-actions { + width: 100%; + max-width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 840px) { + .console-results-content--sql-editor .chroma-results-footer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .console-results-content--sql-editor .chroma-results-footer-range { + white-space: normal; + } + + .console-results-content--sql-editor .chroma-results-footer-pager { + width: 100%; + max-width: 100%; + padding-bottom: 2px; + } +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--statement.sql-editor-parity .console-results-panel { + background: transparent; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .result.result--chroma-workspace { + border-top: 0; + background: transparent; + padding: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-workspace, +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-pane, +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-ops, +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-result-card-body, +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-footer, +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-raw-view { + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-workspace { + padding: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-results-content--sql-editor .chroma-results-pane { + border: 0; + border-radius: 0; + box-shadow: none; + background: #ffffff; +} diff --git a/frontend/src/styles/console/columns-toolbar-mongo.css b/frontend/src/styles/console/columns-toolbar-mongo.css new file mode 100644 index 0000000..989dc27 --- /dev/null +++ b/frontend/src/styles/console/columns-toolbar-mongo.css @@ -0,0 +1,4 @@ +@import "./columns-toolbar-mongo/columns-table.css"; +@import "./columns-toolbar-mongo/index-list.css"; +@import "./columns-toolbar-mongo/toolbar.css"; +@import "./columns-toolbar-mongo/mongo-items.css"; diff --git a/frontend/src/styles/console/columns-toolbar-mongo/columns-table.css b/frontend/src/styles/console/columns-toolbar-mongo/columns-table.css new file mode 100644 index 0000000..d0e5166 --- /dev/null +++ b/frontend/src/styles/console/columns-toolbar-mongo/columns-table.css @@ -0,0 +1,46 @@ + .column-picker { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + } + + .column-picker select { + min-width: 160px; + } + + .btn.small { + min-height: 26px; + padding: 4px 10px; + font-size: 11px; + box-shadow: none; + } + + .columns-table.compact { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + } + + .columns-table.compact th, + .columns-table.compact td { + padding: 4px 6px; + vertical-align: top; + word-break: break-word; + overflow-wrap: anywhere; + } + + .columns-table.compact td { + font-size: 11px; + } + + .columns-table.compact td.col-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-weight: 600; + color: var(--ink); + width: 48%; + } + + .columns-table.compact th { + font-size: 9px; + } diff --git a/frontend/src/styles/console/columns-toolbar-mongo/index-list.css b/frontend/src/styles/console/columns-toolbar-mongo/index-list.css new file mode 100644 index 0000000..7811678 --- /dev/null +++ b/frontend/src/styles/console/columns-toolbar-mongo/index-list.css @@ -0,0 +1,124 @@ + .index-list { + display: grid; + gap: 6px; + } + + .index-list-section + .index-list-section { + margin-top: 8px; + } + + .index-list-section-head { + display: flex; + align-items: baseline; + gap: 6px; + margin: 0 0 4px; + padding: 0 2px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--soft-ink); + } + + .index-list-section-count { + font-weight: 600; + color: color-mix(in oklab, var(--soft-ink) 70%, var(--ink)); + } + + .index-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: start; + column-gap: 8px; + row-gap: 2px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--edge); + background: var(--panel); + min-width: 0; + } + + .index-row.ddb-key-row { + border-color: color-mix(in oklab, var(--primary) 38%, var(--edge)); + background: color-mix(in oklab, var(--primary) 8%, var(--panel)); + } + + .index-kind-pill { + align-self: start; + margin-top: 1px; + flex: 0 0 auto; + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 800; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + line-height: 1.1; + white-space: nowrap; + } + + .index-kind-pill.pk { + border-color: color-mix(in oklab, var(--success) 46%, var(--edge)); + background: var(--success-bg); + color: color-mix(in oklab, var(--success) 92%, var(--ink)); + } + + .index-kind-pill.unique { + border-color: color-mix(in oklab, var(--primary) 50%, var(--edge)); + background: color-mix(in oklab, var(--primary) 14%, var(--panel)); + color: color-mix(in oklab, var(--primary) 92%, var(--ink)); + } + + .index-row-content { + min-width: 0; + display: grid; + gap: 2px; + } + + .index-row-name { + font-size: 11px; + font-weight: 600; + line-height: 1.35; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + word-break: break-word; + overflow-wrap: anywhere; + hyphens: none; + } + + .index-row-name.ddb-key-name { + color: color-mix(in oklab, var(--primary) 92%, var(--ink)); + } + + .index-row-fields { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 4px; + min-width: 0; + font-size: 10.5px; + line-height: 1.3; + color: var(--soft-ink); + word-break: break-word; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + .index-row-fields-label { + flex: 0 0 auto; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: color-mix(in oklab, var(--soft-ink) 80%, var(--ink)); + font-family: inherit; + } + + .index-row-fields-value { + min-width: 0; + word-break: break-word; + overflow-wrap: anywhere; + } diff --git a/frontend/src/styles/console/columns-toolbar-mongo/mongo-items.css b/frontend/src/styles/console/columns-toolbar-mongo/mongo-items.css new file mode 100644 index 0000000..33466f5 --- /dev/null +++ b/frontend/src/styles/console/columns-toolbar-mongo/mongo-items.css @@ -0,0 +1,239 @@ + .result.mongo-items { + max-height: 420px; + } + + .mongo-result-list { + display: grid; + gap: 10px; + } + + .mongo-item { + border: 1px solid var(--edge); + border-radius: 10px; + padding: 8px 10px; + background: var(--panel-strong); + } + + .mongo-item + .mongo-item { + margin-top: 6px; + } + + .mongo-item summary { + cursor: pointer; + font-weight: 600; + color: var(--ink); + font-size: 12px; + outline: none; + display: flex; + align-items: center; + gap: 8px; + line-height: 1.25; + list-style: none; + } + + .mongo-item summary::-webkit-details-marker { + display: none; + } + + .mongo-item-chevron { + width: 14px; + height: 14px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in oklab, var(--ink) 70%, var(--soft-ink)); + } + + .mongo-item-chevron svg { + width: 14px; + height: 14px; + display: block; + transform: rotate(0deg); + transition: transform 120ms ease; + } + + .mongo-item[open] .mongo-item-chevron svg { + transform: rotate(90deg); + } + + .mongo-item[open] { + border-color: color-mix(in oklab, var(--primary) 20%, var(--edge)); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.08); + } + + .mongo-item-head { + display: grid; + gap: 4px; + flex: 1 1 auto; + width: 100%; + min-width: 0; + } + + .mongo-item-title { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .mongo-item-index { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--soft-ink); + } + + .mongo-item-id { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + font-size: 10px; + color: var(--ink); + } + + .mongo-item-preview { + display: grid; + gap: 3px; + padding: 4px 6px; + border-radius: 8px; + border: 1px dashed var(--edge); + background: var(--panel-soft); + } + + .mongo-item-row { + display: grid; + grid-template-columns: minmax(120px, 180px) minmax(0, 1fr); + gap: 8px; + align-items: center; + font-size: 11px; + color: var(--soft-ink); + } + + .mongo-item-row-key { + font-weight: 600; + color: var(--ink); + text-transform: none; + } + + .mongo-item-row-value { + color: var(--soft-ink); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mongo-item-row-muted { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--soft-ink); + } + + .mongo-item-key { + font-weight: 600; + color: var(--ink); + } + + .mongo-item-value { + color: var(--soft-ink); + } + + .mongo-item-body { + margin-top: 6px; + padding-top: 6px; + border-top: 1px dashed var(--edge); + display: grid; + gap: 5px; + } + + .mongo-item-body-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--soft-ink); + } + + .mongo-doc-list { + display: grid; + gap: 4px; + } + + .mongo-doc-line { + display: grid; + grid-template-columns: minmax(140px, 200px) minmax(0, 1fr); + gap: 6px; + align-items: center; + font-size: 10px; + padding: 3px 5px 3px calc(5px + (var(--depth, 0) * 12px)); + border-radius: 7px; + background: var(--panel-soft); + border: 1px solid var(--edge); + } + + .mongo-doc-key { + font-weight: 600; + color: var(--ink); + } + + .mongo-doc-type { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--soft-ink); + padding: 2px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel); + margin-right: 6px; + } + + .mongo-doc-value { + color: var(--soft-ink); + display: flex; + align-items: center; + gap: 6px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + .mongo-raw { + margin-top: 5px; + border-radius: 8px; + border: 1px dashed var(--edge); + padding: 5px 6px; + background: var(--panel); + } + + .mongo-raw summary { + cursor: pointer; + font-size: 10px; + font-weight: 600; + color: var(--soft-ink); + list-style: none; + } + + .mongo-raw summary::-webkit-details-marker { + display: none; + } + + .mongo-raw .json { + margin-top: 5px; + background: var(--panel-soft); + } + + .mongo-item pre { + margin: 0; + font-size: 10px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + background: var(--panel-soft); + border: 1px solid var(--edge); + border-radius: 7px; + } diff --git a/frontend/src/styles/console/columns-toolbar-mongo/toolbar.css b/frontend/src/styles/console/columns-toolbar-mongo/toolbar.css new file mode 100644 index 0000000..8e14d97 --- /dev/null +++ b/frontend/src/styles/console/columns-toolbar-mongo/toolbar.css @@ -0,0 +1,53 @@ + .preview-note { + margin: 6px 0 10px; + color: var(--soft-ink); + font-size: 12px; + } + + .result-toolbar { + margin: 8px 0 6px; + display: none; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + } + + .result-toolbar.show { + display: flex; + } + + .result-toolbar .pager { + display: flex; + align-items: center; + gap: 8px; + } + + .result-toolbar .pager .btn { + padding: 8px 12px; + } + + .result-toolbar select { + width: auto; + } + + .page-toast { + margin-top: 0; + margin-left: 10px; + padding: 0; + font-size: 13px; + line-height: 1; + white-space: nowrap; + color: rgba(13, 92, 50, 0.95); + display: none; + align-self: center; + } + + .page-toast.show { + display: inline; + } + + .btn.is-disabled { + opacity: 0.55; + cursor: not-allowed; + } diff --git a/frontend/src/styles/console/dynamodb-controls.css b/frontend/src/styles/console/dynamodb-controls.css new file mode 100644 index 0000000..1c742bf --- /dev/null +++ b/frontend/src/styles/console/dynamodb-controls.css @@ -0,0 +1,437 @@ +.dynamo-limit-controls { + position: relative; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + white-space: nowrap; +} + +.dynamo-limit-controls .dynamo-limit-trigger, +.editor-toolbar-sql-editor .toolbar-left .dynamo-limit-trigger { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + height: 32px; + padding: 0 10px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: color-mix(in oklab, var(--foreground, #0f172a) 70%, transparent 30%); + font: inherit; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; + transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; + box-shadow: none; +} + +.dynamo-limit-controls .dynamo-limit-trigger:hover, +.editor-toolbar-sql-editor .toolbar-left .dynamo-limit-trigger:hover { + border-color: color-mix(in oklab, var(--primary, #4f46e5) 30%, transparent 70%); + background: color-mix(in oklab, var(--primary, #4f46e5) 8%, transparent 92%); + color: var(--primary, #4f46e5); +} + +.dynamo-limit-controls .dynamo-limit-trigger:focus-visible, +.editor-toolbar-sql-editor .toolbar-left .dynamo-limit-trigger:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary, #4f46e5) 50%, var(--border) 50%); + box-shadow: 0 0 0 3px var(--ring, rgba(79, 70, 229, 0.35)); +} + +.dynamo-limit-controls .dynamo-limit-trigger.is-active, +.editor-toolbar-sql-editor .toolbar-left .dynamo-limit-trigger.is-active { + border-color: color-mix(in oklab, var(--primary, #4f46e5) 40%, transparent 60%); + background: color-mix(in oklab, var(--primary, #4f46e5) 12%, transparent 88%); + color: var(--primary, #4f46e5); +} + +.dynamo-limit-trigger-icon { + color: color-mix(in oklab, var(--primary, #4f46e5) 70%, var(--foreground, #0f172a) 30%); + flex: 0 0 auto; +} + +.dynamo-limit-trigger-label { + font-weight: 600; + letter-spacing: 0.01em; +} + +.dynamo-limit-trigger-summary { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground, #64748b); + background: color-mix(in oklab, var(--primary, #4f46e5) 10%, transparent); + border: 1px solid color-mix(in oklab, var(--primary, #4f46e5) 18%, transparent); +} + +.dynamo-limit-trigger.is-customized .dynamo-limit-trigger-summary { + color: color-mix(in oklab, var(--primary, #4f46e5) 70%, var(--foreground, #0f172a) 30%); + background: color-mix(in oklab, var(--primary, #4f46e5) 16%, transparent); + border-color: color-mix(in oklab, var(--primary, #4f46e5) 32%, transparent); +} + +.dynamo-limit-popover { + position: fixed; + z-index: 1000; + width: 320px; + max-width: calc(100vw - 24px); + max-height: var(--dynamo-popover-max-height, calc(100vh - 24px)); + overflow-y: auto; + padding: 14px; + border-radius: 12px; + background: var(--popover, #fff); + color: var(--popover-foreground, #0f172a); + border: 1px solid var(--border, rgba(99, 102, 241, 0.18)); + box-shadow: + 0 24px 48px color-mix(in oklab, var(--foreground, #0f172a) 18%, transparent), + 0 4px 10px color-mix(in oklab, var(--foreground, #0f172a) 8%, transparent); + display: flex; + flex-direction: column; + gap: 14px; + font-size: 12px; + white-space: normal; + --dynamo-arrow-left: 280px; +} + +.dynamo-limit-popover::before { + content: ""; + position: absolute; + top: -6px; + left: var(--dynamo-arrow-left); + width: 12px; + height: 12px; + background: var(--popover, #fff); + border-top: 1px solid var(--border, rgba(99, 102, 241, 0.18)); + border-left: 1px solid var(--border, rgba(99, 102, 241, 0.18)); + transform: rotate(45deg); + border-top-left-radius: 2px; +} + +.dynamo-limit-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.dynamo-limit-popover-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + font-size: 13px; + color: var(--foreground, #0f172a); +} + +.dynamo-limit-popover-title svg { + color: color-mix(in oklab, var(--primary, #4f46e5) 70%, var(--foreground, #0f172a) 30%); +} + +.dynamo-limit-popover-header-actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dynamo-limit-reset { + display: inline-flex; + align-items: center; + gap: 4px; + height: 24px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--muted-foreground, #64748b); + font: inherit; + font-size: 11px; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.dynamo-limit-reset:hover:not(:disabled) { + background: color-mix(in oklab, var(--primary, #4f46e5) 10%, transparent); + color: color-mix(in oklab, var(--primary, #4f46e5) 80%, var(--foreground, #0f172a) 20%); +} + +.dynamo-limit-reset:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.dynamo-limit-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--muted-foreground, #64748b); + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} + +.dynamo-limit-close:hover { + background: color-mix(in oklab, var(--foreground, #0f172a) 6%, transparent); + color: var(--foreground, #0f172a); +} + +.dynamo-limit-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dynamo-limit-section-title { + margin: 0; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted-foreground, #64748b); +} + +.dynamo-limit-field { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border, rgba(99, 102, 241, 0.14)); + background: color-mix(in oklab, var(--surface-strong, #fff) 50%, transparent); + cursor: text; + transition: border-color 120ms ease, background-color 120ms ease; +} + +.dynamo-limit-field:hover, +.dynamo-limit-field:focus-within { + border-color: color-mix(in oklab, var(--primary, #4f46e5) 35%, var(--border) 65%); + background: color-mix(in oklab, var(--primary, #4f46e5) 4%, var(--surface-strong, #fff) 96%); +} + +.dynamo-limit-field-row { + display: flex; + align-items: center; + gap: 8px; +} + +.dynamo-limit-field-label { + flex: 1 1 auto; + font-size: 12px; + font-weight: 500; + color: var(--foreground, #0f172a); +} + +.dynamo-limit-field-range { + flex: 0 0 auto; + font-size: 10px; + font-variant-numeric: tabular-nums; + color: var(--muted-foreground, #94a3b8); + letter-spacing: 0.02em; +} + +.dynamo-limit-field input { + flex: 0 0 auto; + width: 64px; + height: 26px; + padding: 0 6px; + border: 1px solid var(--border, rgba(99, 102, 241, 0.18)); + border-radius: 6px; + background: var(--input-bg, #fff); + color: var(--foreground, #0f172a); + font: inherit; + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; + line-height: 1; +} + +.dynamo-limit-field input:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary, #4f46e5) 50%, var(--border) 50%); + box-shadow: 0 0 0 3px var(--ring, rgba(79, 70, 229, 0.35)); +} + +.dynamo-limit-field-help { + font-size: 11px; + line-height: 1.4; + color: var(--muted-foreground, #64748b); +} + +.dynamo-limit-popover-footer { + margin: 0; + padding-top: 10px; + border-top: 1px dashed var(--border, rgba(99, 102, 241, 0.16)); + font-size: 11px; + line-height: 1.5; + color: var(--muted-foreground, #64748b); +} + +.dynamo-execution-banner { + display: grid; + gap: 6px; + margin: 10px 12px; + padding: 8px 12px; + border: 1px solid color-mix(in oklab, var(--border, #d1d5db) 100%, transparent); + border-radius: 8px; + background: color-mix(in oklab, var(--primary, #4f46e5) 6%, var(--surface-strong, #fff)); + color: var(--foreground, #111827); + font-size: 12px; + line-height: 1.5; +} + +/* Repair / suggestion hint card — themed, action-forward presentation. */ +.dynamo-hint-card { + --hint-accent: var(--primary, #4f46e5); + display: grid; + gap: 10px; + margin: 10px 12px; + padding: 12px 14px 12px 16px; + position: relative; + border: 1px solid color-mix(in oklab, var(--hint-accent) 22%, var(--border, #d1d5db)); + border-radius: 8px; + background: color-mix(in oklab, var(--hint-accent) 5%, var(--surface-strong, #fff) 95%); + box-shadow: none; + color: var(--foreground, #111827); + overflow: hidden; +} + +.dynamo-hint-card::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 3px; + background: var(--hint-accent); +} + +.dynamo-hint-card--suggestion { + --hint-accent: #f59e0b; +} + +.dynamo-hint-card-header { + display: flex; + align-items: center; + gap: 8px; +} + +.dynamo-hint-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 6px; + background: color-mix(in oklab, var(--hint-accent) 18%, transparent); + color: var(--hint-accent); + flex-shrink: 0; +} + +.dynamo-hint-card-title { + font-size: 13px; + font-weight: 600; + color: var(--foreground, #0f172a); +} + +.dynamo-hint-card-reason { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--muted-foreground, #475569); +} + +.dynamo-hint-card-preview { + display: grid; + gap: 4px; +} + +.dynamo-hint-card-preview-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--muted-foreground, #64748b); + text-transform: uppercase; +} + +.dynamo-hint-card-code { + display: block; + overflow-x: auto; + padding: 8px 10px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--hint-accent) 14%, var(--border, #d1d5db)); + background: color-mix(in oklab, var(--surface-strong, #fff) 92%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre; + color: var(--foreground, #0f172a); +} + +.dynamo-hint-card-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.dynamo-hint-card-button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 28px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--hint-accent) 26%, var(--border, #d1d5db)); + background: var(--surface-strong, #fff); + color: var(--foreground, #0f172a); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; +} + +.dynamo-hint-card-button:hover { + background: color-mix(in oklab, var(--hint-accent) 8%, var(--surface-strong, #fff)); + border-color: color-mix(in oklab, var(--hint-accent) 40%, var(--border, #d1d5db)); +} + +.dynamo-hint-card-button:active { + transform: translateY(1px); +} + +.dynamo-hint-card-button:focus-visible { + outline: 2px solid color-mix(in oklab, var(--hint-accent) 60%, transparent); + outline-offset: 2px; +} + +.dynamo-hint-card-button--primary { + background: var(--hint-accent); + border-color: var(--hint-accent); + color: #fff; + box-shadow: 0 1px 2px color-mix(in oklab, var(--hint-accent) 30%, transparent); +} + +.dynamo-hint-card-button--primary:hover { + background: color-mix(in oklab, var(--hint-accent) 88%, white 12%); + border-color: color-mix(in oklab, var(--hint-accent) 80%, white 20%); +} + +@media (max-width: 640px) { + .dynamo-limit-trigger-summary { + display: none; + } + .dynamo-limit-popover { + width: 280px; + } +} diff --git a/frontend/src/styles/console/elastic-dsl-parity.css b/frontend/src/styles/console/elastic-dsl-parity.css new file mode 100644 index 0000000..2036f28 --- /dev/null +++ b/frontend/src/styles/console/elastic-dsl-parity.css @@ -0,0 +1,1328 @@ +.console-panel--statement.sql-editor-parity .elastic-dsl-workspace { + border-bottom: 1px solid var(--edge); + background: var(--panel-strong, var(--panel)); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-head { + padding: 14px 20px 8px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-head h2 { + margin: 0; + font-size: 13px; + line-height: 1.3; + font-weight: 700; + color: var(--ink); + letter-spacing: -0.01em; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-head p { + margin: 2px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--soft-ink); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip-list { + padding: 0 20px 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice { + margin: 0 20px 10px; + padding: 8px 12px; + border: 1px solid color-mix(in oklab, #f59e0b 30%, var(--edge)); + border-radius: 8px; + background: color-mix(in oklab, #f59e0b 6%, var(--panel)); + color: #92400e; + display: flex; + flex-direction: column; + gap: 2px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice strong { + font-size: 12px; + font-weight: 700; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice span { + font-size: 12px; + line-height: 1.45; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0 10px; + border: 1px solid color-mix(in oklab, var(--primary) 22%, var(--edge)); + border-radius: var(--control-radius, 8px); + background: color-mix(in oklab, var(--primary) 6%, var(--panel-strong, var(--panel))); + color: var(--ink); + cursor: pointer; + flex: 0 0 auto; + font-size: 12px; + white-space: nowrap; + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip:hover { + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + background: color-mix(in oklab, var(--primary) 10%, var(--panel-strong, var(--panel))); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip.is-editing { + border-color: var(--primary); + background: color-mix(in oklab, var(--primary) 12%, var(--panel-strong, var(--panel))); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 14%, transparent); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-field { + color: var(--primary); + font-weight: 600; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-operator { + color: var(--soft-ink); + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-value { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-remove { + width: 16px; + height: 16px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: var(--soft-ink); + background: transparent; + transition: color 0.15s ease, background-color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-remove:hover { + color: var(--danger, #b91c1c); + background: color-mix(in oklab, var(--danger) 10%, transparent); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-toolbar { + padding: 0 20px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-left, +.console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-right { + display: inline-flex; + align-items: center; + gap: 12px; + flex-wrap: nowrap; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-left { + min-width: 0; + flex: 1 1 auto; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-right { + flex: 0 0 auto; + white-space: nowrap; + overflow-x: auto; +} + +.console-panel--statement.sql-editor-parity .elastic-add-filter-btn { + min-height: 32px; + border: 1px solid color-mix(in oklab, var(--primary) 35%, var(--edge)); + border-radius: var(--control-radius, 8px); + padding: 0 12px; + background: var(--panel-strong, var(--panel)); + color: var(--primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .elastic-add-filter-btn:hover { + background: color-mix(in oklab, var(--primary) 8%, var(--panel-strong, var(--panel))); + border-color: color-mix(in oklab, var(--primary) 45%, var(--edge)); +} + +.console-panel--statement.sql-editor-parity .elastic-live-toggle { + display: inline-flex; + align-items: center; + min-height: 32px; + gap: 6px; + padding: 0 4px; + font-size: 11px; + font-weight: 500; + color: var(--soft-ink); + white-space: nowrap; + cursor: pointer; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .elastic-live-icon { + width: 18px; + height: 18px; + border-radius: 4px; + border: 1px solid var(--edge); + background: var(--panel-soft, var(--panel)); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--soft-ink); + font-size: 10px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-panel--statement.sql-editor-parity .elastic-live-toggle input { + margin: 0; + width: 16px; + height: 16px; + min-height: 16px; + accent-color: var(--primary); +} + +.console-panel--statement.sql-editor-parity .elastic-reset-btn { + min-height: 32px; + display: inline-flex; + align-items: center; + border: 0; + background: transparent; + color: var(--soft-ink); + font-size: 11px; + font-weight: 500; + text-decoration: underline dotted; + text-underline-offset: 3px; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; +} + +.console-panel--statement.sql-editor-parity .elastic-reset-btn:hover { + color: var(--ink); +} + +.console-panel--statement.sql-editor-parity .elastic-run-btn { + height: 32px; + border: 1px solid var(--primary); + border-radius: 6px; + padding: 0 14px; + background: var(--primary); + color: var(--primary-foreground, #fff); + font-size: 12px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 1px 2px color-mix(in oklab, var(--primary) 30%, transparent); + white-space: nowrap; + flex: 0 0 auto; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.console-panel--statement.sql-editor-parity .elastic-run-btn:hover:not(:disabled) { + background: color-mix(in oklab, var(--primary) 88%, white 12%); + border-color: color-mix(in oklab, var(--primary) 80%, white 20%); +} + +.console-panel--statement.sql-editor-parity .elastic-run-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor { + margin: 0 20px 12px; + padding: 10px; + border: 1px solid var(--edge); + border-radius: 10px; + background: var(--panel-soft, var(--panel)); + display: flex; + align-items: flex-start; + gap: 8px; + flex-wrap: wrap; + position: relative; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-picker { + position: relative; + flex: 0 0 236px; + min-width: 236px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-picker.is-open { + z-index: 5; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-field-select, +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-operator-select, +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor input[data-testid='elastic-dsl-filter-value']:not(.elastic-dsl-filter-value-input) { + height: 28px; + min-height: 28px; + border-radius: var(--control-radius, 8px); + border: 1px solid var(--edge); + background: var(--input-bg, var(--panel-strong, var(--panel))); + color: var(--ink); + padding: 0 10px; + font-size: 12px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-field-select { + width: 100%; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + cursor: pointer; + text-align: left; + transition: + border-color 120ms ease, + background-color 120ms ease, + box-shadow 120ms ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger:hover { + border-color: #94a3b8; + background: #f8fafc; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-main { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-type { + flex: 0 0 auto; + border-radius: 5px; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + color: #0f766e; + background: #dff7f1; + border: 1px solid #b7e8dc; + max-width: 92px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-chevron { + flex: 0 0 auto; + color: #64748b; + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-popover { + position: fixed; + top: 0; + left: 0; + z-index: 60; + width: min(236px, calc(100vw - 32px)); + max-height: min(284px, calc(100vh - 32px)); + display: flex; + flex-direction: column; + border-radius: 12px; + border: 1px solid #d7e0ea; + background: color-mix(in oklab, #ffffff 96%, #f1f5f9 4%); + box-shadow: + 0 18px 36px -28px rgba(15, 23, 42, 0.45), + 0 10px 18px -16px rgba(15, 23, 42, 0.24); + overflow: hidden; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-search { + position: sticky; + top: 0; + z-index: 1; + padding: 8px; + border-bottom: 1px solid #e2e8f0; + background: rgba(248, 250, 252, 0.96); + backdrop-filter: blur(8px); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-search input { + width: 100%; + height: 32px; + min-height: 32px; + border-radius: 7px; + border: 1px solid #cbd5e1; + background: #ffffff; + color: #334155; + padding: 0 10px; + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-list { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px; + overflow: auto; + max-height: 224px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-option { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: #334155; + padding: 7px 8px; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-option:hover { + background: #ecfdf5; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-option.is-selected { + background: #e0f2fe; + box-shadow: inset 3px 0 0 #14b8a6; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-option .field-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-option .field-option-type { + font-size: 10px; + color: #94a3b8; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-field-empty { + padding: 10px; + color: #94a3b8; + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor .elastic-dsl-filter-operator-select { + --elastic-dsl-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2357687f' stroke-width='1.35' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + width: 118px; + flex: 0 0 118px; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + cursor: pointer; + padding-right: 30px; + border-color: var(--sql-editor-border); + background-color: var(--sql-editor-surface); + background-image: var(--elastic-dsl-select-chevron); + box-shadow: none; + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 10px 6px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor .elastic-dsl-filter-operator-select:hover { + border-color: color-mix(in oklab, var(--primary) 30%, var(--sql-editor-border) 70%); + background-color: color-mix(in oklab, var(--primary) 4%, var(--sql-editor-surface) 96%); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor .elastic-dsl-filter-operator-select:focus-visible, +.console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary) 34%, var(--sql-editor-border) 66%); + box-shadow: + 0 0 0 3px color-mix(in oklab, var(--primary) 14%, transparent), + inset 0 1px 0 color-mix(in oklab, #fff 18%, transparent), + 0 1px 2px color-mix(in oklab, var(--sql-editor-bg) 18%, transparent); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor input[data-testid='elastic-dsl-filter-value']:not(.elastic-dsl-filter-value-input) { + width: 220px; + flex: 1 1 220px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-composer { + min-height: 28px; + min-width: 220px; + flex: 1 1 220px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + border-radius: var(--control-radius, 8px); + border: 1px solid var(--edge); + background: var(--input-bg, var(--panel-strong, var(--panel))); + padding: 3px 8px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-actions { + margin-left: auto; + display: inline-flex; + align-items: flex-start; + gap: 8px; + flex: 0 0 auto; + white-space: nowrap; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-value-token { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + border-radius: 999px; + padding: 2px 8px; + background: color-mix(in oklab, var(--primary) 8%, var(--panel)); + border: 1px solid color-mix(in oklab, var(--primary) 18%, var(--edge)); + color: var(--ink); + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-composer .elastic-dsl-value-token-remove { + height: auto; + min-height: 0; + border: 0; + border-radius: 0; + background: transparent; + color: #64748b; + font-size: 12px; + font-weight: 400; + line-height: 1; + padding: 0; + cursor: pointer; + box-shadow: none; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-input { + flex: 1 1 120px; + min-width: 120px; + height: 22px; + min-height: 22px; + border: 0; + background: transparent; + color: var(--ink); + padding: 0; + font-size: 12px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-input:focus { + outline: none; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button { + height: 28px; + border-radius: var(--control-radius, 8px); + border: 1px solid var(--edge); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + padding: 0 12px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + flex: 0 0 auto; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button:hover { + background: color-mix(in oklab, var(--primary) 6%, var(--panel-strong, var(--panel))); + border-color: color-mix(in oklab, var(--primary) 22%, var(--edge)); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button.ghost { + color: var(--soft-ink); + background: transparent; + border-color: color-mix(in oklab, var(--edge) 60%, transparent); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer { + border-top: 1px solid var(--sql-editor-border); + background: var(--sql-editor-surface); + margin: 0; + padding: 18px 20px 22px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + flex: 1 1 240px; + min-width: 0; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status h4 { + margin: 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #52637a; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status .sync-pill { + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #a7f3d0; + color: #047857; + background: #ecfdf5; + font-size: 11px; + font-weight: 600; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status .json-validity { + font-size: 11px; + font-weight: 600; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status .json-validity.ok { + color: #16a34a; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status .json-validity.error { + color: #dc2626; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + max-width: 100%; + overflow-x: auto; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; + padding-bottom: 2px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button { + height: 32px; + border-radius: 9px; + border: 1px solid #d8e4f4; + padding: 0 12px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(243, 248, 255, 0.92) 100%); + color: #344256; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + flex: 0 0 auto; + cursor: pointer; + box-shadow: 0 8px 18px -18px rgba(15, 23, 42, 0.4); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button:hover { + border-color: #bfd3ee; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(236, 244, 255, 0.95) 100%); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button.is-copy { + border-color: #bfdbfe; + color: #2563eb; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell { + --elastic-dsl-code-font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + --elastic-dsl-code-font-size: 13px; + --elastic-dsl-code-line-height: 18px; + --elastic-dsl-code-padding: 14px 20px 16px; + border: 1px solid #d8e4f4; + border-radius: 18px; + overflow: hidden; + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + height: var(--elastic-dsl-editor-height, 320px); + min-height: var(--elastic-dsl-editor-height, 320px); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 250, 255, 0.94) 100%); + box-shadow: + 0 1px 3px rgba(15, 23, 42, 0.06), + 0 24px 46px -34px rgba(15, 23, 42, 0.45); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell:focus-within { + border-color: #93c5fd; + box-shadow: + 0 0 0 1px rgba(147, 197, 253, 0.9), + 0 24px 46px -34px rgba(15, 23, 42, 0.45); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-line-numbers { + position: relative; + border-right: 1px solid rgba(203, 213, 225, 0.76); + background: + linear-gradient(180deg, rgba(239, 245, 255, 0.92) 0%, rgba(231, 238, 249, 0.9) 100%); + color: #7f8ea3; + font-family: var(--elastic-dsl-code-font-family); + overflow: hidden; + user-select: none; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-line-numbers-inner { + position: absolute; + inset: 0 auto auto 0; + width: 100%; + padding: 14px 8px 16px; + will-change: transform; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-line-number { + display: block; + height: 18px; + padding-right: 8px; + text-align: right; + line-height: 18px; + font-size: 11px; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane { + --elastic-dsl-scrollbar-opacity: 0; + --elastic-dsl-scrollbar-thumb-height: 0px; + --elastic-dsl-scrollbar-thumb-offset: 0px; + position: relative; + overflow: hidden; + display: grid; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(246, 250, 255, 0.94) 100%); +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane::before { + content: ''; + position: absolute; + inset: 12px 6px 12px auto; + width: 6px; + border-radius: 999px; + background: rgba(226, 232, 240, 0.86); + opacity: var(--elastic-dsl-scrollbar-opacity); + pointer-events: none; + z-index: 3; + transition: opacity 140ms ease; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane::after { + content: ''; + position: absolute; + top: 12px; + right: 6px; + width: 6px; + height: var(--elastic-dsl-scrollbar-thumb-height); + border-radius: 999px; + background: rgba(96, 124, 159, 0.94); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38); + transform: translateY(var(--elastic-dsl-scrollbar-thumb-offset)); + opacity: var(--elastic-dsl-scrollbar-opacity); + pointer-events: none; + z-index: 3; + transition: opacity 140ms ease, transform 80ms linear; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-highlight { + grid-area: 1 / 1; + width: 100%; + height: auto; + min-height: 100%; + margin: 0; + padding: 14px 20px 16px; + padding: var(--elastic-dsl-code-padding); + font-family: var(--elastic-dsl-code-font-family); + font-size: 13px; + font-size: var(--elastic-dsl-code-font-size); + line-height: 18px; + line-height: var(--elastic-dsl-code-line-height); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + tab-size: 2; + position: absolute; + inset: 0 auto auto 0; + pointer-events: none; + overflow: visible; + color: #203246; + z-index: 1; + will-change: transform; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor-scrollbar-mask { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 18px; + background: + linear-gradient(90deg, rgba(246, 250, 255, 0) 0%, rgba(246, 250, 255, 0.94) 38%, rgba(246, 250, 255, 0.98) 100%); + pointer-events: none; + z-index: 2; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor { + grid-area: 1 / 1; + width: 100%; + height: var(--elastic-dsl-editor-height, 320px); + min-height: var(--elastic-dsl-editor-height, 320px); + margin: 0; + padding: 14px 20px 16px; + padding: var(--elastic-dsl-code-padding); + font-family: var(--elastic-dsl-code-font-family); + font-size: 13px; + font-size: var(--elastic-dsl-code-font-size); + line-height: 18px; + line-height: var(--elastic-dsl-code-line-height); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + tab-size: 2; + border: 0; + resize: none; + color: transparent; + caret-color: #0f172a; + background: transparent; + overflow: auto; + scrollbar-width: none; + z-index: 1; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor::-webkit-scrollbar { + width: 0; + height: 0; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-json-token { + font-family: var(--elastic-dsl-code-font-family); + font-size: inherit; + font-weight: inherit; + font-style: inherit; + line-height: inherit; +} + +/* JSON token colors: see elastic-json-tokens.css */ + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor:focus { + outline: none; +} + +.console-panel--statement.sql-editor-parity .elastic-dsl-editor::selection { + background: rgba(59, 130, 246, 0.18); +} + +@media (max-width: 980px) { + + .console-panel--statement.sql-editor-parity .elastic-dsl-head, + .console-panel--statement.sql-editor-parity .elastic-dsl-chip-list, + .console-panel--statement.sql-editor-parity .elastic-dsl-toolbar { + padding-left: 12px; + padding-right: 12px; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice { + margin: 0 12px 14px; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-toolbar { + flex-direction: column; + align-items: stretch; + overflow-x: auto; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-left { + justify-content: flex-start; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-toolbar-right { + width: 100%; + max-width: 100%; + justify-content: flex-start; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor { + margin-left: 12px; + margin-right: 12px; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-drawer { + margin-left: -12px; + margin-right: -12px; + padding-left: 12px; + padding-right: 12px; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell { + grid-template-columns: 48px minmax(0, 1fr); + height: var(--elastic-dsl-editor-height, 320px); + min-height: var(--elastic-dsl-editor-height, 320px); + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-editor-highlight, + .console-panel--statement.sql-editor-parity .elastic-dsl-editor { + height: var(--elastic-dsl-editor-height, 320px); + min-height: var(--elastic-dsl-editor-height, 320px); + padding-left: 20px; + padding-right: 20px; + } +} + +@media (max-width: 840px) { + + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-field-picker, + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-operator-select, + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-composer, + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor input[data-testid='elastic-dsl-filter-value']:not(.elastic-dsl-filter-value-input) { + width: 100%; + min-width: 0; + flex: auto; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-filter-actions { + margin-left: 0; + justify-content: flex-end; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-field-popover { + max-height: min(240px, calc(100vh - 120px)); + } + +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + grid-template-rows: auto minmax(0, 1fr); +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .console-editor-results-splitter { + display: none; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-statement-panel--elastic-stitch { + grid-template-rows: auto minmax(0, 1fr); + min-height: max-content; + background: #f5f7fa; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-statement-panel--elastic-stitch .statement-shell--sql-editor { + max-height: 0; + min-height: 0; + overflow: hidden; + border: 0; + padding: 0; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-statement-panel--elastic-stitch .statement-monaco { + height: 0; + min-height: 0; + opacity: 0; + pointer-events: none; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-workspace { + margin: 0; + border: 0; + border-bottom: 1px solid #e2e8f0; + border-radius: 0; + overflow: visible; + box-shadow: none; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-head, +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-chip-list, +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-toolbar, +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor, +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-drawer, +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice { + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell { + --elastic-dsl-code-font-family: 'JetBrains Mono', 'IBM Plex Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +@media (max-width: 840px) { + + .console-shell.sql-editor-parity.elastic-stitch .console-statement-panel--elastic-stitch { + min-height: 0; + } + + .console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-workspace { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + } + + .console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + grid-template-rows: auto minmax(220px, 1fr); + } +} + +@media (max-width: 760px) { + + .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-head { + flex-wrap: wrap; + align-items: flex-start; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status { + width: 100%; + } + + .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + overflow-x: visible; + } + + .console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + grid-template-rows: auto minmax(240px, 1fr); + } +} + +/* ══════════════════════════════════════════════════════ + Dark mode overrides + ══════════════════════════════════════════════════════ */ + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-workspace { + border-bottom-color: rgba(255, 255, 255, 0.08); + background: var(--background); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-head h2 { + color: #f1f5f9; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-head p { + color: rgba(255, 255, 255, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-unsupported-notice { + border-color: rgba(252, 211, 77, 0.3); + background: linear-gradient(180deg, rgba(252, 211, 77, 0.1) 0%, rgba(252, 211, 77, 0.06) 100%); + color: #fcd34d; +} + +/* chips */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-chip { + border-color: rgba(129, 140, 248, 0.2); + background: rgba(129, 140, 248, 0.08); + color: rgba(255, 255, 255, 0.8); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-chip:hover { + border-color: rgba(129, 140, 248, 0.3); + background: rgba(129, 140, 248, 0.14); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-field { + color: #a5b4fc; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-operator { + color: rgba(255, 255, 255, 0.4); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-chip .chip-remove { + color: #a5b4fc; + background: rgba(255, 255, 255, 0.06); +} + +/* toolbar buttons */ +.dark .console-panel--statement.sql-editor-parity .elastic-add-filter-btn { + border-color: rgba(129, 140, 248, 0.4); + background: rgba(129, 140, 248, 0.08); + color: #a5b4fc; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-add-filter-btn:hover { + background: rgba(129, 140, 248, 0.16); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-live-toggle { + color: rgba(255, 255, 255, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-live-icon { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-live-toggle input { + accent-color: #818cf8; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-reset-btn { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-reset-btn:hover { + color: rgba(255, 255, 255, 0.8); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-run-btn { + background: #14b8a6; + box-shadow: 0 4px 10px rgba(20, 184, 166, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-run-btn:hover:not(:disabled) { + background: #0d9488; +} + +/* filter editor */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor { + border-color: rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.02) 100%); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-field-select, +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-operator-select, +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor input[data-testid='elastic-dsl-filter-value']:not(.elastic-dsl-filter-value-input) { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: #f1f5f9; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger:hover { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.06); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-type { + color: #5eead4; + background: rgba(94, 234, 212, 0.1); + border-color: rgba(94, 234, 212, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-trigger-chevron { + color: rgba(255, 255, 255, 0.4); +} + +/* field popover */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-popover { + border-color: rgba(255, 255, 255, 0.1); + background: #12121a; + box-shadow: + 0 18px 36px -28px rgba(0, 0, 0, 0.8), + 0 10px 18px -16px rgba(0, 0, 0, 0.5); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-search { + border-bottom-color: rgba(255, 255, 255, 0.08); + background: rgba(18, 18, 26, 0.96); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-search input { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: #f1f5f9; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-option { + color: rgba(255, 255, 255, 0.8); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-option:hover { + background: rgba(129, 140, 248, 0.1); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-option.is-selected { + background: rgba(129, 140, 248, 0.14); + box-shadow: inset 3px 0 0 #818cf8; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-option .field-option-type { + color: rgba(255, 255, 255, 0.35); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-field-empty { + color: rgba(255, 255, 255, 0.35); +} + +/* value composer */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-composer { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-value-token { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.8); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-composer .elastic-dsl-value-token-remove { + color: rgba(255, 255, 255, 0.4); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-value-input { + color: #f1f5f9; +} + +/* filter editor buttons */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.8); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button:hover { + background: rgba(255, 255, 255, 0.08); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-filter-editor button.ghost { + color: rgba(255, 255, 255, 0.5); +} + +/* drawer (DSL editor area) */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer { + border-top-color: rgba(255, 255, 255, 0.08); + background: + radial-gradient(circle at top right, rgba(99, 102, 241, 0.08) 0%, transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.01) 100%); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status h4 { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-status .sync-pill { + border-color: rgba(167, 243, 208, 0.3); + color: #5eead4; + background: rgba(94, 234, 212, 0.1); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.8); + box-shadow: 0 8px 18px -18px rgba(0, 0, 0, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button:hover { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-drawer-actions button.is-copy { + border-color: rgba(129, 140, 248, 0.3); + color: #a5b4fc; +} + +/* code editor shell */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell { + border-color: rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.2), + 0 24px 46px -34px rgba(0, 0, 0, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-shell:focus-within { + border-color: rgba(129, 140, 248, 0.4); + box-shadow: + 0 0 0 1px rgba(129, 140, 248, 0.3), + 0 24px 46px -34px rgba(0, 0, 0, 0.6); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-line-numbers { + border-right-color: rgba(255, 255, 255, 0.06); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.02) 100%); + color: rgba(255, 255, 255, 0.25); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.01) 100%); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane::before { + background: rgba(255, 255, 255, 0.06); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-pane::after { + background: rgba(255, 255, 255, 0.2); + box-shadow: none; +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-scrollbar-mask { + background: + linear-gradient(90deg, transparent 0%, rgba(10, 10, 15, 0.94) 38%, rgba(10, 10, 15, 0.98) 100%); +} + +/* code syntax colors (dark) */ +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor-highlight { + color: rgba(255, 255, 255, 0.7); +} + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor { + caret-color: #f1f5f9; +} + +/* Dark JSON token colors: see elastic-json-tokens.css */ + +.dark .console-panel--statement.sql-editor-parity .elastic-dsl-editor::selection { + background: rgba(129, 140, 248, 0.25); +} + +/* elastic-stitch overrides */ +.dark .console-shell.sql-editor-parity.elastic-stitch .console-statement-panel--elastic-stitch { + background: var(--background); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .elastic-dsl-workspace { + border-bottom-color: rgba(255, 255, 255, 0.08); +} diff --git a/frontend/src/styles/console/elastic-json-tokens.css b/frontend/src/styles/console/elastic-json-tokens.css new file mode 100644 index 0000000..b0650a4 --- /dev/null +++ b/frontend/src/styles/console/elastic-json-tokens.css @@ -0,0 +1,27 @@ +/* Shared JSON syntax-highlight token colors for Elasticsearch views. + Used by both the DSL editor overlay and the results expanded-row / raw JSON. */ + +:root { + --elastic-json-key: #2f71ae; + --elastic-json-string: #128562; + --elastic-json-number: #b77512; + --elastic-json-literal: #1f68a9; + --elastic-json-brace: #5e738a; + --elastic-json-punctuation: #8da0b4; +} + +.dark { + --elastic-json-key: #93c5fd; + --elastic-json-string: #5eead4; + --elastic-json-number: #fbbf24; + --elastic-json-literal: #a5b4fc; + --elastic-json-brace: rgba(255, 255, 255, 0.5); + --elastic-json-punctuation: rgba(255, 255, 255, 0.35); +} + +.elastic-dsl-json-token-key { color: var(--elastic-json-key); } +.elastic-dsl-json-token-string { color: var(--elastic-json-string); } +.elastic-dsl-json-token-number { color: var(--elastic-json-number); } +.elastic-dsl-json-token-literal { color: var(--elastic-json-literal); } +.elastic-dsl-json-token-brace { color: var(--elastic-json-brace); } +.elastic-dsl-json-token-punctuation { color: var(--elastic-json-punctuation); } diff --git a/frontend/src/styles/console/elastic-results-parity.css b/frontend/src/styles/console/elastic-results-parity.css new file mode 100644 index 0000000..a8a8559 --- /dev/null +++ b/frontend/src/styles/console/elastic-results-parity.css @@ -0,0 +1,865 @@ +.console-results-content--sql-editor .result.result--elastic-workspace { + display: flex; + flex-direction: column; + border-top: 0; + background: transparent; + box-shadow: none; + overflow: hidden; +} + +.console-results-content--sql-editor .elastic-results-workspace { + --elastic-surface: var(--panel-strong); + --elastic-surface-soft: color-mix(in oklab, var(--panel-soft) 82%, transparent); + --elastic-surface-muted: color-mix(in oklab, var(--panel) 88%, transparent); + --elastic-border: color-mix(in oklab, var(--edge) 78%, var(--primary) 22%); + --elastic-border-soft: color-mix(in oklab, var(--edge) 88%, transparent); + --elastic-text: color-mix(in oklab, var(--ink) 92%, var(--primary) 8%); + --elastic-strong: var(--ink); + --elastic-muted: color-mix(in oklab, var(--soft-ink) 84%, var(--primary) 16%); + --elastic-accent: color-mix(in oklab, var(--primary) 76%, var(--ink) 24%); + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 12px 0; + box-sizing: border-box; + background: transparent; +} + +.console-results-content--sql-editor .elastic-results-pane { + flex: 1 1 auto; + min-height: 0; + height: auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + border: 1px solid var(--elastic-border-soft); + border-radius: 10px; + background: var(--elastic-surface); + box-shadow: var(--surface-shadow-soft); + overflow: hidden; +} + +.console-results-content--sql-editor .elastic-results-ops { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 0; + border-radius: 0; + background: var(--elastic-surface); + box-shadow: none; + padding: 10px 12px; + overflow-x: auto; + border-bottom: 1px solid var(--elastic-border-soft); +} + +.console-results-content--sql-editor .elastic-results-ops-summary { + min-width: 0; +} + +.console-results-content--sql-editor .elastic-results-ops-summary h3 { + margin: 0; + color: var(--elastic-strong); + font-size: 14px; + line-height: 1.2; + font-weight: 700; +} + +.console-results-content--sql-editor .elastic-results-ops-meta { + margin-left: 8px; + color: var(--elastic-muted); + font-size: 12px; + font-weight: 600; +} + +.console-results-content--sql-editor .elastic-results-ops-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + white-space: nowrap; + overflow-x: auto; +} + +.console-results-content--sql-editor .elastic-results-view-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + border: 1px solid var(--elastic-border-soft); + border-radius: 7px; + background: var(--elastic-surface-soft); + padding: 2px; +} + +.console-results-content--sql-editor .elastic-ops-button { + flex: 0 0 auto; + min-height: 32px; + border-radius: 6px; + border: 1px solid var(--elastic-border-soft); + background: var(--elastic-surface); + color: var(--elastic-muted); + font-size: 11px; + font-weight: 600; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.console-results-content--sql-editor .elastic-results-view-toggle .elastic-ops-button { + border-color: transparent; + background: transparent; + min-height: 32px; + padding: 0 9px; +} + +.console-results-content--sql-editor .elastic-results-view-toggle .elastic-ops-button.active { + border-color: var(--elastic-border-soft); + background: var(--elastic-surface); + color: var(--elastic-accent); +} + +.console-results-content--sql-editor .elastic-ops-button--icon { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.console-results-content--sql-editor .elastic-ops-icon { + width: 15px; + height: 15px; + stroke-width: 2; + transition: transform 0.15s ease; +} + +.console-results-content--sql-editor .elastic-ops-icon.is-open { + transform: rotate(180deg); +} + +.console-results-content--sql-editor .elastic-ops-button:hover { + border-color: var(--elastic-border); + color: var(--elastic-accent); + background: var(--elastic-surface-soft); +} + +.console-results-content--sql-editor .elastic-results-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.console-results-content--sql-editor .elastic-results-list-wrap { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.console-results-content--sql-editor .elastic-results-list { + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 100%; + min-height: 0; + border: 0; + border-radius: 0; + overflow: hidden; + background: transparent; + box-shadow: none; +} + +.console-results-content--sql-editor .elastic-results-list-wrap>.meta { + margin: 16px; + border: 1px dashed var(--elastic-border-soft); + border-radius: 8px; + background: var(--elastic-surface-soft); + color: var(--elastic-muted); + padding: 16px; + text-align: center; +} + +.console-results-content--sql-editor .elastic-results-table-head-wrap { + flex: 0 0 auto; + overflow: hidden; + border-bottom: 1px solid var(--elastic-border-soft); + background: var(--elastic-surface); + box-sizing: border-box; + padding-right: var(--elastic-results-body-scrollbar-width, 0px); +} + +.console-results-content--sql-editor .elastic-results-table-wrap { + flex: 1 1 auto; + min-height: 0; + height: auto; + overflow: auto; + border-top: 0; +} + +.console-results-content--sql-editor .elastic-results-table { + width: max-content; + min-width: 100%; + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: var(--elastic-text); +} + +.console-results-content--sql-editor .elastic-results-table thead th { + z-index: 3; + border-bottom: 0; + background: color-mix(in oklab, var(--elastic-surface) 94%, var(--elastic-surface-soft) 6%); + color: var(--elastic-strong); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-align: left; + padding: 9px 14px; + text-transform: uppercase; + min-width: 64px; +} + +.console-results-content--sql-editor .elastic-results-table th, +.console-results-content--sql-editor .elastic-results-table td { + border-left: 0; + border-right: 0; +} + +.console-results-content--sql-editor .elastic-results-table th.elastic-col-toggle, +.console-results-content--sql-editor .elastic-results-table td.elastic-cell-toggle { + width: 44px; + min-width: 44px; + max-width: 44px; + text-align: center; + padding: 0; +} + +.console-results-content--sql-editor .elastic-results-table th.elastic-col-toggle { + background: var(--elastic-surface); + border-bottom-color: transparent; + min-width: 44px; +} + +.console-results-content--sql-editor .elastic-results-table td.elastic-cell-toggle { + background: transparent; +} + +.console-results-content--sql-editor .elastic-results-row td { + padding: 8px 14px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + vertical-align: middle; +} + +.console-results-content--sql-editor .elastic-results-row td:not(.elastic-cell-toggle) { + border-top: 1px solid var(--elastic-border-soft); + min-width: 64px; + max-width: 320px; +} + +.console-results-content--sql-editor .elastic-results-row td:not(.elastic-cell-toggle) { + cursor: pointer; +} + +.console-results-content--sql-editor .elastic-results-row:first-child td:not(.elastic-cell-toggle) { + border-top: 0; +} + +.console-results-content--sql-editor .elastic-results-row:nth-child(even) td:not(.elastic-cell-toggle) { + background: color-mix(in oklab, var(--elastic-surface-soft) 50%, transparent); +} + +.console-results-content--sql-editor .elastic-results-row:hover td:not(.elastic-cell-toggle) { + background: color-mix(in oklab, var(--elastic-surface-soft) 82%, var(--primary) 18%); +} + +.console-results-content--sql-editor .elastic-results-row.is-open td:not(.elastic-cell-toggle) { + border-top-color: color-mix(in oklab, var(--elastic-border-soft) 70%, var(--primary) 30%); + background: color-mix(in oklab, var(--elastic-surface-soft) 76%, var(--primary) 24%); +} + +.console-results-content--sql-editor .elastic-row-toggle { + width: 32px; + height: 32px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--elastic-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.console-results-content--sql-editor .elastic-row-toggle:hover { + border-color: var(--elastic-border-soft); + background: var(--elastic-surface-soft); + color: var(--elastic-accent); +} + +.console-results-content--sql-editor .elastic-result-chevron { + width: 14px; + height: 14px; + stroke-width: 2.2; + transform: rotate(0deg); + transition: transform 0.15s ease; +} + +.console-results-content--sql-editor .elastic-results-row.is-open .elastic-result-chevron { + transform: rotate(90deg); +} + +.console-results-content--sql-editor .elastic-result-cell { + min-width: 0; +} + +.console-results-content--sql-editor .elastic-results-table thead th.elastic-result-head--width-xs, +.console-results-content--sql-editor .elastic-result-cell--width-xs { + width: 72px; + min-width: 72px; + max-width: 72px; +} + +.console-results-content--sql-editor .elastic-results-table thead th.elastic-result-head--width-sm, +.console-results-content--sql-editor .elastic-result-cell--width-sm { + width: 128px; + min-width: 128px; + max-width: 128px; +} + +.console-results-content--sql-editor .elastic-results-table thead th.elastic-result-head--width-md, +.console-results-content--sql-editor .elastic-result-cell--width-md { + width: 196px; + min-width: 196px; + max-width: 196px; +} + +.console-results-content--sql-editor .elastic-results-table thead th.elastic-result-head--width-lg, +.console-results-content--sql-editor .elastic-result-cell--width-lg { + width: 280px; + min-width: 280px; + max-width: 280px; +} + +.console-results-content--sql-editor .elastic-result-cell .value { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--elastic-strong); +} + +.console-results-content--sql-editor .elastic-value-pill { + display: inline-flex; + align-items: center; + max-width: 100%; + min-height: 24px; + padding: 2px 10px; + border-radius: 999px; + border: 1px solid var(--elastic-border-soft); + font-size: 11px; + line-height: 1.35; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-value-pill--identifier { + color: color-mix(in oklab, var(--elastic-text) 84%, #94a3b8 16%); + background: color-mix(in oklab, #94a3b8 10%, var(--elastic-surface)); + border-color: color-mix(in oklab, #94a3b8 28%, var(--edge)); + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-results-content--sql-editor .elastic-value-pill--timestamp { + color: color-mix(in oklab, #38bdf8 74%, var(--elastic-text)); + background: color-mix(in oklab, #38bdf8 12%, var(--elastic-surface)); + border-color: color-mix(in oklab, #38bdf8 32%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-value-pill--number { + color: color-mix(in oklab, #3b82f6 82%, var(--elastic-text)); + background: color-mix(in oklab, #3b82f6 12%, var(--elastic-surface)); + border-color: color-mix(in oklab, #3b82f6 30%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-value-pill--boolean { + color: color-mix(in oklab, #10b981 82%, var(--elastic-text)); + background: color-mix(in oklab, #10b981 12%, var(--elastic-surface)); + border-color: color-mix(in oklab, #10b981 30%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-value-pill--array { + color: color-mix(in oklab, #8b5cf6 74%, var(--elastic-text)); + background: color-mix(in oklab, #8b5cf6 10%, var(--elastic-surface)); + border: 1px solid color-mix(in oklab, #8b5cf6 28%, var(--edge)); + border-color: color-mix(in oklab, #8b5cf6 28%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-value-pill--object { + color: color-mix(in oklab, #06b6d4 74%, var(--elastic-text)); + background: color-mix(in oklab, #06b6d4 10%, var(--elastic-surface)); + border: 1px solid color-mix(in oklab, #06b6d4 28%, var(--edge)); + border-color: color-mix(in oklab, #06b6d4 28%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-value-pill--keyword { + color: color-mix(in oklab, #f59e0b 78%, var(--elastic-text)); + background: color-mix(in oklab, #f59e0b 10%, var(--elastic-surface)); + border-color: color-mix(in oklab, #f59e0b 28%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-result-cell .type-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.console-results-content--sql-editor .elastic-result-cell .type-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex: 0 0 6px; +} + +.console-results-content--sql-editor .elastic-result-cell .type-dot--success { + background: #10b981; +} + +.console-results-content--sql-editor .elastic-result-cell .type-dot--danger { + background: #ef4444; +} + +.console-results-content--sql-editor .elastic-result-cell .type-dot--warning { + background: #f59e0b; +} + +.console-results-content--sql-editor .elastic-result-cell .type-dot--info { + background: #3b82f6; +} + +.console-results-content--sql-editor .elastic-result-cell .status-pill { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--elastic-border-soft); + font-size: 11px; + line-height: 1.35; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-result-cell .status-pill--success { + color: color-mix(in oklab, var(--success) 90%, #0f172a); + background: color-mix(in oklab, var(--success-bg) 88%, var(--panel)); + border-color: color-mix(in oklab, var(--success) 44%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-result-cell .status-pill--danger { + color: color-mix(in oklab, var(--danger) 90%, #7f1d1d); + background: color-mix(in oklab, var(--danger-bg) 84%, var(--panel)); + border-color: color-mix(in oklab, var(--danger) 42%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-result-cell .status-pill--warning { + color: #b45309; + background: color-mix(in oklab, #f59e0b 14%, var(--panel)); + border-color: color-mix(in oklab, #f59e0b 30%, var(--edge)); +} + +.console-results-content--sql-editor .elastic-result-cell .status-pill--neutral { + color: var(--elastic-muted); + background: var(--elastic-surface-soft); +} + +.console-results-content--sql-editor .elastic-results-row-detail td { + padding: 0; + border-top: 0; + background: color-mix(in oklab, var(--elastic-surface-soft) 80%, transparent); +} + +.console-results-content--sql-editor .elastic-result-card-body { + border-top: 1px solid var(--elastic-border-soft); + background: transparent; + padding: 0; +} + +.console-results-content--sql-editor .elastic-result-body-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px 8px; +} + +.console-results-content--sql-editor .elastic-result-card-body h5 { + margin: 0; + color: var(--elastic-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.console-results-content--sql-editor .elastic-result-body-actions { + display: inline-flex; + align-items: center; + gap: 10px; + overflow-x: auto; +} + +.console-results-content--sql-editor .elastic-result-body-actions button { + border: 0; + padding: 0; + background: transparent; + color: var(--elastic-accent); + font-size: 11px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-result-body-actions button:hover { + color: color-mix(in oklab, var(--primary) 84%, #ffffff 16%); +} + +.console-results-content--sql-editor .elastic-cell-context-menu { + position: fixed; + z-index: 12; + min-width: 168px; + padding: 6px; + border: 1px solid var(--elastic-border-soft); + border-radius: 10px; + background: color-mix(in oklab, var(--elastic-surface) 96%, #05070d 4%); + box-shadow: 0 18px 30px rgba(15, 23, 42, 0.2); +} + +.console-results-content--sql-editor .elastic-cell-context-menu button { + width: 100%; + min-height: 32px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--elastic-text); + font-size: 11px; + font-weight: 600; + text-align: left; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-cell-context-menu button:hover { + background: var(--elastic-surface-soft); + color: var(--elastic-accent); +} + +.console-results-content--sql-editor .elastic-result-metadata { + margin: 0 12px 8px; + border: 1px solid var(--elastic-border-soft); + border-radius: 8px; + background: var(--elastic-surface); + padding: 8px 10px; + display: grid; + gap: 4px; +} + +.console-results-content--sql-editor .elastic-result-metadata .meta-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.console-results-content--sql-editor .elastic-result-metadata .meta-key { + color: var(--elastic-muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + flex: 0 0 auto; +} + +.console-results-content--sql-editor .elastic-result-metadata .meta-value { + color: var(--elastic-text); + font-size: 11px; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + min-width: 0; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-result-card-body pre { + margin: 0; + min-height: 200px; + max-height: 340px; + overflow: auto; + border-top: 1px solid var(--elastic-border-soft); + background: color-mix(in oklab, var(--elastic-surface) 92%, var(--elastic-surface-soft) 8%); + color: var(--elastic-text); + padding: 12px; + font-size: 12px; + line-height: 1.6; + border-radius: 0 0 12px 12px; +} + +/* JSON token colors: see elastic-json-tokens.css */ + +.console-results-content--sql-editor .elastic-results-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--elastic-border-soft); + background: color-mix(in oklab, var(--elastic-surface) 94%, var(--elastic-surface-soft) 6%); + color: var(--elastic-strong); + font-size: 12px; + font-weight: 500; +} + +.console-results-content--sql-editor .elastic-results-footer-range { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-results-content--sql-editor .elastic-results-footer-note { + color: var(--elastic-accent); +} + +.console-results-content--sql-editor .elastic-results-footer-pager { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + white-space: nowrap; + overflow-x: auto; +} + +.console-results-content--sql-editor .elastic-results-footer-pager button { + height: 32px; + min-width: 32px; + border-radius: 7px; + border: 1px solid var(--elastic-border-soft); + background: var(--elastic-surface); + color: var(--elastic-muted); + font-size: 12px; + font-weight: 700; + padding: 0 8px; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +.console-results-content--sql-editor .elastic-results-footer-pager button:hover:not(:disabled) { + border-color: var(--elastic-border); + color: var(--elastic-accent); + background: var(--elastic-surface-soft); +} + +.console-results-content--sql-editor .elastic-results-footer-pager button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.console-results-content--sql-editor .elastic-results-footer-pager .elastic-page-number.active { + border-color: color-mix(in oklab, var(--primary) 52%, var(--elastic-border-soft)); + background: color-mix(in oklab, var(--primary) 12%, var(--elastic-surface)); + color: var(--elastic-accent); +} + +.console-results-content--sql-editor .elastic-results-footer-pager .elastic-page-number:disabled { + min-width: 18px; + padding: 0 2px; + border-color: transparent; + background: transparent; + opacity: 0.85; + cursor: default; +} + +.console-results-content--sql-editor .elastic-results-raw-view { + min-height: 0; + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + border: 0; + border-radius: 0; + overflow: hidden; + background: transparent; + box-shadow: none; +} + +.console-results-content--sql-editor .elastic-results-raw-toolbar { + padding: 8px 10px; + color: var(--elastic-muted); + border-bottom: 1px solid var(--elastic-border-soft); + background: var(--elastic-surface-muted); + font-size: 11px; + font-weight: 600; +} + +.console-results-content--sql-editor .elastic-results-raw-view pre { + margin: 0; + padding: 12px; + overflow: auto; + background: color-mix(in oklab, var(--panel) 84%, #0b1220 16%); + color: var(--elastic-text); + font-size: 12px; + line-height: 1.65; +} + +.dark .console-results-content--sql-editor .elastic-results-workspace { + --elastic-surface: color-mix(in oklab, var(--panel-strong) 90%, #06090f 10%); + --elastic-surface-soft: color-mix(in oklab, var(--panel-soft) 82%, #05070d 18%); + --elastic-surface-muted: color-mix(in oklab, var(--panel) 82%, #05070d 18%); + --elastic-border: color-mix(in oklab, var(--edge) 70%, var(--primary) 30%); + --elastic-border-soft: color-mix(in oklab, var(--edge) 90%, #10151f 10%); +} + +.dark .console-results-content--sql-editor .elastic-results-pane { + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.22); +} + +.dark .console-results-content--sql-editor .elastic-results-row:hover td:not(.elastic-cell-toggle) { + background: color-mix(in oklab, var(--panel-soft) 70%, var(--primary) 30%); +} + +.dark .console-results-content--sql-editor .elastic-result-cell .status-pill--warning { + color: #facc15; + background: color-mix(in oklab, #f59e0b 24%, #0b1220 76%); +} + +/* Dark JSON token colors: see elastic-json-tokens.css */ + +@media (max-width: 980px) { + .console-results-content--sql-editor .elastic-results-workspace { + padding: 8px 0; + } + + .console-results-content--sql-editor .elastic-results-ops { + flex-direction: column; + align-items: stretch; + padding: 8px; + } + + .console-results-content--sql-editor .elastic-results-ops-actions { + width: 100%; + max-width: 100%; + justify-content: flex-start; + } + + .console-results-content--sql-editor .elastic-result-body-head, + .console-results-content--sql-editor .elastic-result-metadata, + .console-results-content--sql-editor .elastic-result-card-body pre { + margin-left: 8px; + margin-right: 8px; + } + + .console-results-content--sql-editor .elastic-result-card-body pre { + padding: 10px; + } +} + +@media (max-width: 840px) { + .console-results-content--sql-editor .elastic-results-footer { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .console-results-content--sql-editor .elastic-results-footer-range { + white-space: normal; + } + + .console-results-content--sql-editor .elastic-results-footer-pager { + width: 100%; + max-width: 100%; + padding-bottom: 2px; + } +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--statement.sql-editor-parity .console-results-panel { + background: transparent; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result.result--elastic-workspace { + border-top: 0; + background: transparent; + padding: 0; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-workspace, +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-pane, +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-ops, +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-result-card-body, +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-footer, +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-raw-view { + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +/* Flatten the results pane to integrate with Query Builder */ +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-workspace { + padding: 0; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .elastic-results-pane { + border: 0; + border-radius: 0; + box-shadow: none; + background: #ffffff; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result-footer-sql-editor.result-footer-sql-editor--elastic { + height: 40px; + border-top: 1px solid color-mix(in oklab, var(--edge) 86%, var(--primary) 14%); + padding: 0 12px; + background: transparent; + color: color-mix(in oklab, var(--soft-ink) 90%, var(--primary) 10%); + font-size: 12px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result-footer-sql-editor.result-footer-sql-editor--elastic .pager { + gap: 6px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result-footer-sql-editor.result-footer-sql-editor--elastic .pager button { + min-width: 28px; + width: auto; + height: 28px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--edge) 80%, var(--primary) 20%); + padding: 0 8px; + background: var(--panel); + color: var(--soft-ink); + box-shadow: none; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result-footer-sql-editor.result-footer-sql-editor--elastic .pager button.active { + border-color: color-mix(in oklab, var(--primary) 60%, var(--edge)); + background: color-mix(in oklab, var(--primary) 14%, var(--panel)); + color: color-mix(in oklab, var(--primary) 84%, var(--ink) 16%); +} + +.console-shell.sql-editor-parity.elastic-stitch .console-results-content--sql-editor .result-footer-sql-editor.result-footer-sql-editor--elastic .pager button:disabled { + opacity: 0.5; + box-shadow: none; +} diff --git a/frontend/src/styles/console/explain-pagination-status.css b/frontend/src/styles/console/explain-pagination-status.css new file mode 100644 index 0000000..89a327f --- /dev/null +++ b/frontend/src/styles/console/explain-pagination-status.css @@ -0,0 +1,278 @@ + .explain-card { + border: 1px solid var(--edge); + border-radius: 12px; + padding: 10px 12px; + background: var(--panel-strong); + display: grid; + gap: 10px; + } + + .explain-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + .explain-card h5 { + margin: 0 0 2px; + font-size: 13px; + } + + .explain-subtitle { + margin: 0; + font-size: 11px; + } + + .explain-narrative { + margin: 0; + font-size: 12px; + color: var(--ink); + background: var(--panel-soft); + border: 1px solid var(--edge); + border-radius: 10px; + padding: 8px 10px; + } + + .explain-readable { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--edge); + border-radius: 10px; + background: var(--panel-soft); + } + + .explain-readable.success { + background: var(--success-bg); + border-color: color-mix(in oklab, var(--success) 45%, var(--edge)); + } + + .explain-readable.danger { + background: var(--danger-bg); + border-color: color-mix(in oklab, var(--danger) 45%, var(--edge)); + } + + .explain-readable-title { + margin: 0; + font-size: 12px; + font-weight: 600; + color: var(--ink); + } + + .explain-readable ul { + margin: 7px 0 0; + padding-left: 18px; + color: var(--soft-ink); + font-size: 12px; + line-height: 1.45; + } + + .explain-readable li + li { + margin-top: 4px; + } + + .explain-narrative.success { + background: var(--success-bg); + border-color: color-mix(in oklab, var(--success) 45%, var(--edge)); + } + + .explain-narrative.danger { + background: var(--danger-bg); + border-color: color-mix(in oklab, var(--danger) 45%, var(--edge)); + } + + .explain-chip { + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + border: 1px solid; + white-space: nowrap; + } + + .explain-chip.success { + background: var(--success-bg); + border-color: var(--success); + color: var(--success); + } + + .explain-chip.danger { + background: var(--danger-bg); + border-color: var(--danger); + color: var(--danger); + } + + .explain-chip.warning { + background: rgba(234, 179, 8, 0.16); + border-color: #eab308; + color: #b45309; + } + + .explain-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .explain-stat { + display: grid; + gap: 2px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid var(--edge); + background: var(--panel-soft); + min-width: 110px; + } + + .explain-stat-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--soft-ink); + } + + .explain-stat-value { + font-size: 12px; + font-weight: 600; + color: var(--ink); + } + + .explain-detail { + border-radius: 10px; + border: 1px solid var(--edge); + background: var(--panel); + padding: 8px; + } + + .explain-lines { + display: grid; + gap: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + color: var(--ink); + } + + .explain-line { + padding: 6px 8px; + border-radius: 8px; + background: var(--panel-soft); + border: 1px solid var(--edge); + } + + .explain-detail .json { + margin: 0; + background: transparent; + border: none; + padding: 0; + } + + .result-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 8px 0 6px; + flex-wrap: wrap; + } + + .result-pagination-left { + display: flex; + align-items: center; + gap: 8px; + } + + .page-tip-anchor { + position: relative; + display: inline-flex; + align-items: center; + } + + .page-label { + font-size: 12px; + color: var(--soft-ink); + } + + .result-pagination-right { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--soft-ink); + } + + .result-pagination-right select { + width: auto; + min-width: 72px; + } + + .result-pagination .btn.is-disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .page-tip { + display: inline-flex; + align-items: center; + margin-left: 8px; + font-size: 11px; + line-height: 1.2; + color: #b45309; + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.4); + border-radius: 999px; + padding: 2px 8px; + white-space: nowrap; + pointer-events: none; + animation: page-tip-fade 1.5s ease forwards; + } + + @keyframes page-tip-fade { + 0% { + opacity: 0; + transform: translateY(-2px); + } + 15% { + opacity: 1; + transform: translateY(0); + } + 80% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-2px); + } + } + + .statement-status { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + font-size: 11px; + line-height: 1.35; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } + + .statement-status.success { + border: 1px solid rgba(29, 157, 88, 0.35); + background: rgba(29, 157, 88, 0.08); + color: rgba(13, 92, 50, 0.95); + } + + .statement-status.failed { + border: 1px solid rgba(208, 75, 26, 0.4); + background: rgba(208, 75, 26, 0.08); + color: var(--accent-dark); + } + + .statement-status.warning { + border: 1px solid rgba(234, 179, 8, 0.4); + background: rgba(234, 179, 8, 0.12); + color: #b45309; + } diff --git a/frontend/src/styles/console/layout-entities-tabs.css b/frontend/src/styles/console/layout-entities-tabs.css new file mode 100644 index 0000000..0ae1bcd --- /dev/null +++ b/frontend/src/styles/console/layout-entities-tabs.css @@ -0,0 +1,3 @@ +@import "./layout-entities-tabs/layout.css"; +@import "./layout-entities-tabs/entities.css"; +@import "./layout-entities-tabs/results.css"; diff --git a/frontend/src/styles/console/layout-entities-tabs/entities.css b/frontend/src/styles/console/layout-entities-tabs/entities.css new file mode 100644 index 0000000..2af2474 --- /dev/null +++ b/frontend/src/styles/console/layout-entities-tabs/entities.css @@ -0,0 +1,1029 @@ + .console-panel--entities { + display: flex; + flex-direction: column; + min-height: 0; + } + + .entity-list { + flex: 1; + min-height: 0; + max-height: none; + overflow: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; + gap: 4px; + } + + .entity-item { + padding: 3px 6px; + min-height: 32px; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + font-size: 12px; + line-height: 16px; + color: var(--ink); + } + + .entity-item:hover { + border-color: var(--edge); + background: var(--panel-soft); + } + + .entity-item.active { + border-color: color-mix(in oklab, var(--primary) 46%, var(--edge)); + background: color-mix(in oklab, var(--primary) 14%, var(--panel)); + } + + .console-panel--entities .entity-item.active { + border-left: 2px solid color-mix(in oklab, var(--primary) 75%, var(--edge)); + padding-left: 5px; + } + + .redis-tree-row { + display: flex; + align-items: center; + gap: 5px; + } + + .redis-tree-toggle { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--soft-ink); + cursor: pointer; + padding: 0; + border-radius: 8px; + flex: 0 0 auto; + } + + .redis-tree-toggle:hover { + background: var(--panel); + color: var(--ink); + } + + .redis-tree-toggle.placeholder { + visibility: hidden; + } + + .redis-tree-folder { + color: var(--ink); + } + + .redis-tree-key { + color: var(--soft-ink); + font-weight: 500; + } + + .redis-tree-count { + margin-left: auto; + font-size: 10px; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + } + + .entity-title { + font-weight: 600; + letter-spacing: 0.01em; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .es-index-meta { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + } + + .es-health-pill { + font-size: 10px; + font-weight: 700; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel); + color: var(--soft-ink); + } + + .es-health-pill.green { + background: var(--success-bg); + border-color: color-mix(in oklab, var(--success) 45%, var(--edge)); + color: color-mix(in oklab, var(--success) 92%, var(--ink)); + } + + .es-health-pill.yellow { + background: color-mix(in oklab, #ca8a04 14%, var(--panel)); + border-color: color-mix(in oklab, #ca8a04 45%, var(--edge)); + color: color-mix(in oklab, #ca8a04 92%, var(--ink)); + } + + .es-health-pill.red { + background: var(--danger-bg); + border-color: color-mix(in oklab, var(--danger) 45%, var(--edge)); + color: color-mix(in oklab, var(--danger) 92%, var(--ink)); + } + + .entity-kind-pill { + font-size: 9px; + font-weight: 600; + padding: 0 4px; + border-radius: 3px; + line-height: 16px; + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.02em; + } + + .entity-kind-pill.entity-kind--view { + background: color-mix(in oklab, var(--accent) 14%, var(--panel)); + border: 1px solid color-mix(in oklab, var(--accent) 40%, var(--edge)); + color: color-mix(in oklab, var(--accent) 85%, var(--ink)); + } + + .es-store-size { + font-size: 10px; + font-weight: 600; + color: var(--soft-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + .entity-subtitle { + margin-top: 2px; + font-size: 12px; + color: var(--soft-ink); + } + + #view-console .list-toolbar { + gap: 12px; + align-items: flex-start; + } + + .console-toolbar-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + } + + .console-toolbar-title > #console-title, + .console-toolbar-title > #console-subtitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + + .console-actions { + display: flex; + gap: 6px; + margin-top: 10px; + flex-wrap: wrap; + flex: 0 1 auto; + min-width: 0; + } + + #view-console .list-toolbar .console-actions .btn { + min-height: 28px; + padding: 3px 10px; + font-size: 11.5px; + gap: 4px; + } + + .console-datasource-dropdown { + position: relative; + width: clamp(200px, 24vw, 320px); + max-width: min(320px, 60vw); + } + + .console-datasource-trigger { + width: 100%; + justify-content: flex-start; + gap: 6px; + padding-inline: 6px 8px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + } + + .console-datasource-trigger:disabled { + cursor: not-allowed; + } + + .console-datasource-trigger-icon, + .console-datasource-option-icon { + width: 18px; + height: 18px; + border-radius: 6px; + padding: 2px; + } + + .console-datasource-trigger-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + font-size: 12px; + line-height: 1.35; + font-weight: 550; + letter-spacing: 0.01em; + } + + .console-datasource-trigger-arrow { + font-size: 10px; + color: var(--soft-ink); + } + + .console-datasource-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 6; + width: 100%; + min-width: 100%; + max-width: min(520px, calc(100vw - 28px)); + max-height: 280px; + overflow: auto; + padding: 6px; + border: 1px solid color-mix(in oklab, var(--edge) 80%, var(--primary) 12%); + border-radius: 12px; + background: color-mix(in oklab, var(--panel-strong) 92%, #fff); + box-shadow: 0 14px 28px rgba(15, 23, 42, 0.16); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + } + + .console-datasource-option { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--ink); + padding: 8px 10px; + text-align: left; + cursor: pointer; + } + + .console-datasource-option:hover { + background: color-mix(in oklab, var(--panel-soft) 84%, transparent); + } + + .console-datasource-option.active { + border-color: color-mix(in oklab, var(--primary) 32%, var(--edge)); + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + } + + .console-datasource-option-label { + flex: 1; + min-width: 0; + font-size: 13px; + font-weight: 550; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.01em; + } + + .console-datasource-empty { + padding: 8px 10px; + color: var(--soft-ink); + font-size: 12px; + } + + @media (max-width: 1200px) { + #view-console .list-toolbar { + flex-direction: column; + align-items: stretch; + } + + #view-console .list-toolbar .console-actions { + width: 100%; + margin-top: 4px; + } + } + + @media (max-width: 900px) { + .console-datasource-dropdown { + min-width: 100%; + max-width: 100%; + width: 100%; + } + + .console-datasource-trigger { + width: 100%; + } + + .console-datasource-menu { + left: 0; + right: auto; + min-width: 100%; + max-width: 100%; + } + } + + .entity-entry { + display: flex; + flex-direction: column; + gap: 4px; + flex: 0 0 auto; + } + + .entity-item { + display: flex; + align-items: center; + gap: 6px; + } + + .entity-toggle { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + background: transparent; + color: var(--soft-ink); + cursor: pointer; + padding: 0; + border-radius: 8px; + flex: 0 0 auto; + } + + .entity-toggle-icon { + width: 7px; + height: 7px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.16s ease; + } + + .entity-toggle-icon.open { + transform: rotate(45deg) translateY(-1px); + } + + .entity-toggle:hover { + border-color: var(--edge); + background: var(--panel); + color: var(--ink); + } + + .entity-item.expanded .entity-toggle { + color: var(--ink); + } + + .entity-expand { + margin-left: 18px; + padding: 6px; + border-radius: 10px; + border: 1px solid var(--edge); + background: var(--paper); + display: grid; + gap: 6px; + } + + .entity-expand-tabs { + display: inline-flex; + gap: 4px; + align-items: center; + } + + .entity-expand-tab { + border: 1px solid var(--edge); + background: var(--panel); + color: var(--soft-ink); + border-radius: 999px; + padding: 2px 8px; + font-size: 10px; + cursor: pointer; + } + + .entity-expand-tab.active { + background: color-mix(in oklab, var(--primary) 14%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + color: var(--ink); + } + + .entity-expand-tab:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .entity-expand-panel { + display: grid; + gap: 4px; + } + + .detail-list { + display: grid; + gap: 6px; + } + + .detail-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + padding: 4px 6px; + border-radius: 10px; + border: 1px solid var(--edge); + background: var(--panel); + min-width: 0; + } + + .detail-label { + flex: 0 0 auto; + font-size: 10px; + font-weight: 700; + color: var(--soft-ink); + } + + .detail-value { + flex: 1; + min-width: 0; + text-align: right; + font-size: 11px; + font-weight: 600; + color: var(--ink); + overflow-wrap: anywhere; + word-break: break-word; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + .es-index-fields-panel { + display: grid; + gap: 6px; + border-radius: 10px; + border: 1px solid var(--edge); + background: color-mix(in oklab, var(--panel) 88%, transparent); + padding: 6px; + } + + .es-index-fields-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .es-index-fields-label { + font-size: 10px; + font-weight: 700; + color: var(--soft-ink); + text-transform: uppercase; + letter-spacing: 0.06em; + } + + .es-index-fields-count { + font-size: 10px; + color: var(--soft-ink); + border-radius: 999px; + border: 1px solid var(--edge); + background: var(--panel-soft); + padding: 1px 6px; + } + + .es-index-fields-filter { + height: 26px; + min-height: 26px; + border-radius: 6px; + border: 1px solid var(--edge); + background: var(--panel); + color: var(--ink); + padding: 0 8px; + font-size: 11px; + } + + .es-index-fields-list { + max-height: 188px; + overflow: auto; + display: grid; + gap: 2px; + padding-right: 2px; + } + + .es-index-field-item { + display: flex; + align-items: center; + gap: 6px; + min-height: 24px; + border-radius: 6px; + padding: 2px 4px; + cursor: pointer; + } + + .es-index-field-item:hover { + background: color-mix(in oklab, var(--panel-soft) 72%, transparent); + } + + .es-index-field-item input[type="checkbox"] { + width: 16px; + height: 16px; + min-height: 16px; + flex: 0 0 16px; + margin: 0; + } + + .es-index-field-name { + flex: 1; + min-width: 0; + font-size: 11px; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .es-index-field-type { + flex: 0 0 auto; + font-size: 9px; + line-height: 1.2; + text-transform: uppercase; + color: var(--soft-ink); + border: 1px solid var(--edge); + border-radius: 999px; + padding: 1px 5px; + letter-spacing: 0.04em; + } + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities { + background: #f8fafc; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .panel-head { + padding: 14px 12px 8px; +} + +/* + * Direction A (TASK-20260513-195708): the entities title used to be 18px under + * elastic-stitch. The shared sql-editor-parity rule now sets 13px/600, and we + * intentionally drop the elastic-stitch override so Mongo/MySQL/Postgres/ES/ + * DynamoDB/D1 share one panel-title metric. + */ + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern-label { + color: #64748b; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern { + border-color: #cbd5e1; + border-radius: 6px; + background: #ffffff; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern-hint { + color: #64748b; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-list { + gap: 0; + padding: 0 8px 8px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-entry { + gap: 0; + content-visibility: auto; + contain-intrinsic-size: auto 32px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-item { + min-height: 32px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + padding: 0 8px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-item:hover { + background: #e2e8f0; + border-color: transparent; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-item.active { + border-left: 0; + border-color: transparent; + background: #e2e8f0; + box-shadow: none; + padding-left: 8px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-toggle { + width: 32px; + height: 32px; + border: 0; + border-radius: 10px; + color: #94a3b8; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-toggle:hover { + color: #64748b; + background: transparent; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-title { + font-size: 12px; + font-weight: 600; + color: #334155; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-meta { + gap: 8px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-health-pill { + width: 8px; + height: 8px; + padding: 0; + border: 0; + border-radius: 999px; + overflow: hidden; + color: transparent; + font-size: 0; + line-height: 0; + background: #94a3b8; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-health-pill.green { + background: #22c55e; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-health-pill.yellow { + background: #eab308; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-health-pill.red { + background: #ef4444; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-store-size { + font-size: 11px; + color: #64748b; +} + +@media (max-width: 760px) { + .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-meta { + gap: 4px; + } + + .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-store-size { + display: none; + } +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-expand { + margin-left: 18px; + border: 0; + background: transparent; + padding: 0; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-panel { + border: 0; + background: transparent; + border-radius: 0; + padding: 0; + gap: 8px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-label { + font-size: 11px; + color: #64748b; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-count { + border-color: #cbd5e1; + background: #e2e8f0; + color: #64748b; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-filter { + height: 30px; + min-height: 30px; + border-color: #cbd5e1; + border-radius: 6px; + background: #ffffff; + color: #475569; + font-size: 12px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-list { + max-height: 200px; + gap: 3px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-item { + min-height: 26px; + border-radius: 6px; + padding: 2px 6px; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-item:hover { + background: #f1f5f9; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-name { + font-size: 12px; + color: #475569; +} + +.console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-type { + border-color: #d1d5db; + background: #f8fafc; + color: #94a3b8; +} + +/* ══════════════════════════════════════════════════════ + Dark mode overrides — elastic-stitch entities panel + ══════════════════════════════════════════════════════ */ + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities { + background: var(--background); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern-label { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: #f1f5f9; +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities #entity-pattern-hint { + color: rgba(255, 255, 255, 0.4); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-item.active { + background: rgba(129, 140, 248, 0.1); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-toggle { + color: rgba(255, 255, 255, 0.35); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-toggle:hover { + color: rgba(255, 255, 255, 0.7); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .entity-title { + color: rgba(255, 255, 255, 0.8); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-store-size { + color: rgba(255, 255, 255, 0.4); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-label { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-count { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-fields-filter { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.8); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-name { + color: rgba(255, 255, 255, 0.7); +} + +.dark .console-shell.sql-editor-parity.elastic-stitch .console-panel--entities .es-index-field-type { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.35); +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities { + background: #f8fafc; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', ui-sans-serif, system-ui, sans-serif; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .panel-head { + padding: 14px 12px 8px; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern-label, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern-hint { + color: #64748b; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern { + border-color: #cbd5e1; + border-radius: 6px; + background: #ffffff; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-list { + gap: 0; + padding: 0 8px 8px; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-entry { + gap: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-item { + min-height: 32px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + padding: 0 8px; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-item:hover { + background: #e2e8f0; + border-color: transparent; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-item.active { + border-left: 0; + border-color: transparent; + background: #e2e8f0; + box-shadow: none; + padding-left: 8px; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-toggle { + width: 32px; + height: 32px; + border: 0; + border-radius: 10px; + color: #94a3b8; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-title { + font-size: 12px; + font-weight: 600; + color: #334155; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-collection-inline { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-collection-badge { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 20px; + padding: 0 6px; + border-radius: 999px; + border: 1px solid #dbe4f0; + background: #eef4ff; + color: #475569; + font-size: 10px; + font-weight: 600; + white-space: nowrap; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-badge-icon { + font-size: 9px; + font-weight: 700; + color: #94a3b8; + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-expand-panel--chroma, +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-expand { + padding: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-expand { + margin-left: 18px; + border: 0; + background: transparent; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-list { + display: flex; + flex-direction: column; + gap: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 5px 0; + border-bottom: 1px solid #f1f5f9; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-row:last-child { + border-bottom: 0; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-label { + flex: 0 0 auto; + min-width: 52px; + font-size: 11px; + font-weight: 600; + color: #64748b; + white-space: nowrap; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-value { + flex: 1 1 0; + min-width: 0; + font-size: 11px; + font-weight: 500; + color: #1e293b; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-value.is-mono { + font-family: 'JetBrains Mono', 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 10px; + color: #475569; + user-select: all; +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities { + background: var(--background); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern-label, +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern-hint { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities #entity-pattern { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: #f1f5f9; +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-item.active { + background: rgba(59, 130, 246, 0.12); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-toggle { + color: rgba(255, 255, 255, 0.35); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .entity-title { + color: rgba(255, 255, 255, 0.84); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-collection-badge { + border-color: rgba(96, 165, 250, 0.22); + background: rgba(59, 130, 246, 0.12); + color: rgba(191, 219, 254, 0.92); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-row { + border-bottom-color: rgba(255, 255, 255, 0.06); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-label { + color: rgba(255, 255, 255, 0.5); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-value { + color: rgba(255, 255, 255, 0.84); +} + +.dark .console-shell.sql-editor-parity.chroma-stitch .console-panel--entities .chroma-detail-value.is-mono { + color: rgba(255, 255, 255, 0.6); +} diff --git a/frontend/src/styles/console/layout-entities-tabs/layout.css b/frontend/src/styles/console/layout-entities-tabs/layout.css new file mode 100644 index 0000000..bf7ad98 --- /dev/null +++ b/frontend/src/styles/console/layout-entities-tabs/layout.css @@ -0,0 +1,140 @@ + #view-console { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .console-shell { + display: grid; + grid-template-columns: var(--console-left, 300px) 10px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + gap: 16px; + flex: 1; + min-height: 0; + overflow: hidden; + } + + .console-shell.resizing { + user-select: none; + } + + body.console-resizing { + cursor: col-resize; + user-select: none; + } + + body.console-resizing-row { + cursor: row-resize; + user-select: none; + } + + .console-splitter { + width: 10px; + cursor: col-resize; + position: relative; + } + + .console-splitter::before { + content: ""; + position: absolute; + left: 50%; + top: 8px; + bottom: 8px; + width: 2px; + border-radius: 999px; + background: var(--edge); + transform: translateX(-50%); + } + + .console-splitter:hover::before { + background: color-mix(in oklab, var(--primary) 30%, var(--edge)); + } + + .console-panel { + min-height: 0; + } + + .console-editor-results-shell { + display: grid; + grid-template-rows: minmax(220px, var(--console-editor-height, 56%)) 8px minmax(180px, 1fr); + gap: 0; + min-height: 0; + height: 100%; + } + + .console-editor-results-shell.resizing { + user-select: none; + } + + .console-editor-results-shell > * { + min-height: 0; + } + + .console-editor-results-splitter { + width: 100%; + height: 8px; + cursor: row-resize; + position: relative; + background: transparent; + } + + .console-editor-results-splitter::before { + content: ""; + position: absolute; + left: 8px; + right: 8px; + top: 50%; + height: 2px; + border-radius: 999px; + background: var(--edge); + transform: translateY(-50%); + } + + .console-editor-results-splitter:hover::before { + background: color-mix(in oklab, var(--primary) 30%, var(--edge)); + } + + .panel { + background: var(--panel); + border: 1px solid var(--edge); + border-radius: 14px; + padding: 14px; + box-shadow: var(--surface-shadow-soft); + } + + .panel h4 { + margin: 0 0 12px; + font-size: 14px; + } + + .panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .panel-head-actions { + display: flex; + align-items: center; + gap: 8px; + } + + #mongo-change-db { + display: none; + align-items: center; + gap: 4px; + padding: 6px; + min-width: 0; + } + + #mongo-change-db svg { + width: 14px; + height: 14px; + } + + #mongo-change-db .mongo-change-db-label { + display: none; + } diff --git a/frontend/src/styles/console/layout-entities-tabs/results.css b/frontend/src/styles/console/layout-entities-tabs/results.css new file mode 100644 index 0000000..6bf894c --- /dev/null +++ b/frontend/src/styles/console/layout-entities-tabs/results.css @@ -0,0 +1,161 @@ + .result-tabs { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .result-tab { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 4px 10px; + font-size: 11px; + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + cursor: pointer; + max-width: 100%; + } + + .result-tab:hover { + background: var(--panel); + color: var(--ink); + } + + .result-tab.active { + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + color: var(--ink); + } + + .result-tab-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--soft-ink); + flex: 0 0 auto; + } + + .result-tab-dot.success { + background: var(--success); + } + + .result-tab-dot.failed { + background: var(--danger); + } + + .result-tab-dot.warning { + background: rgba(234, 179, 8, 0.95); + } + + .result-tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 220px; + } + + .result-tabs-clear { + margin-left: auto; + } + + .explain-options { + margin-top: 8px; + display: flex; + align-items: center; + gap: 8px; + color: var(--soft-ink); + font-size: 12px; + } + + .explain-options input { + accent-color: var(--accent); + } + + .result-meta-row { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .result-meta { + font-size: 11px; + color: var(--soft-ink); + background: var(--panel-soft); + border: 1px solid var(--edge); + border-radius: 999px; + padding: 4px 10px; + } + + .result-meta.result-meta--success { + color: color-mix(in oklab, var(--success) 88%, var(--ink) 12%); + border-color: color-mix(in oklab, var(--success) 45%, var(--edge)); + background: color-mix(in oklab, var(--success) 14%, var(--panel-soft)); + } + + .result-meta-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + } + + .result-meta-actions .btn.ghost { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 10%, var(--panel)) 0%, + color-mix(in oklab, var(--primary) 6%, var(--panel)) 100% + ); + color: var(--ink); + border-color: color-mix(in oklab, var(--primary) 30%, var(--edge)); + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5); + } + + .result-meta-actions .btn.ghost:hover { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 14%, var(--panel)) 0%, + color-mix(in oklab, var(--primary) 8%, var(--panel)) 100% + ); + border-color: color-mix(in oklab, var(--primary) 38%, var(--edge)); + } + + .result-viz-builder { + margin-top: 10px; + border: 1px solid var(--edge); + border-radius: 14px; + background: color-mix(in oklab, var(--panel-soft) 88%, var(--primary) 12%); + padding: 12px; + display: grid; + gap: 10px; + } + + .result-viz-builder-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + .result-viz-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + align-items: end; + } + + .result-viz-field { + display: grid; + gap: 6px; + } + + .result-viz-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } diff --git a/frontend/src/styles/console/results-history-responsive.css b/frontend/src/styles/console/results-history-responsive.css new file mode 100644 index 0000000..6e8bce1 --- /dev/null +++ b/frontend/src/styles/console/results-history-responsive.css @@ -0,0 +1,326 @@ + .console-panel--statement { + display: flex; + flex-direction: column; + min-height: 0; + } + + .console-results-content { + display: flex; + flex-direction: column; + min-height: 0; + } + + .console-results-content--dialog { + flex: 1; + min-height: 0; + } + + .result { + margin-top: 14px; + padding: 6px; + flex: 1; + min-height: 0; + max-height: none; + overflow: auto; + overscroll-behavior: contain; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel); + } + + .result--mongo .mongo-result-list { + background: color-mix(in oklab, var(--panel-soft) 88%, var(--primary) 12%); + border-radius: 12px; + padding: 8px; + } + + .mongo-result-shell { + display: grid; + gap: 8px; + } + + .result-copy-row { + display: flex; + justify-content: flex-end; + gap: 6px; + } + + .result-copy-row .btn { + white-space: nowrap; + } + + .result--sql { + padding-top: 2px; + } + + .result--sql .result-table-shell { + margin-top: -4px; + } + + .result-table-shell { + display: grid; + gap: 8px; + } + + .result-table-toolbar { + position: sticky; + top: 0; + display: flex; + justify-content: flex-end; + gap: 6px; + padding: 4px 2px; + background: var(--panel); + border-radius: 10px; + z-index: 1; + } + + .result-table { + width: max-content; + min-width: 100%; + } + + .result-table th, + .result-table td { + white-space: nowrap; + } + + .result-table td { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + } + + .row-copy-button { + width: 26px; + height: 24px; + padding: 0; + border-radius: 8px; + border: 1px solid color-mix(in oklab, var(--edge) 70%, transparent); + background: transparent; + color: color-mix(in oklab, var(--ink) 70%, var(--soft-ink)); + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0; + transform: translateY(-2px); + transition: + opacity 120ms ease, + transform 120ms ease, + border-color 120ms ease, + background 120ms ease, + color 120ms ease; + pointer-events: none; + } + + .row-copy-button .copy-icon { + width: 14px; + height: 14px; + } + + .result-table tbody tr:hover .row-copy-button, + .result-table tbody tr:focus-within .row-copy-button, + .mongo-item:hover .row-copy-button, + .mongo-item:focus-within .row-copy-button, + .row-copy-button:focus-visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + color: var(--ink); + border-color: color-mix(in oklab, var(--primary) 28%, var(--edge)); + background: color-mix(in oklab, var(--panel-soft) 70%, transparent); + } + + .row-copy-button:hover { + background: color-mix(in oklab, var(--panel-soft) 82%, transparent); + } + + @media (hover: none) { + .row-copy-button { + opacity: 1; + transform: none; + pointer-events: auto; + } + } + + .result-table-copy { + text-align: left; + white-space: nowrap; + width: 1%; + } + + .result-json { + display: grid; + gap: 6px; + } + + .mongo-item-copy { + flex: 0 0 auto; + margin-left: auto; + align-self: flex-start; + } + + .result-paging-loading { + padding: 6px 4px; + } + + .history { + margin-top: 12px; + } + + .history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + + .history-head h4 { + margin: 0; + } + + .history-more-btn { + border-color: color-mix(in oklab, var(--primary) 32%, var(--edge)); + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 12%, transparent) 0%, + color-mix(in oklab, var(--primary) 6%, transparent) 100% + ); + color: color-mix(in oklab, var(--primary) 72%, var(--ink)); + letter-spacing: 0.08px; + } + + .history-more-btn:hover { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 18%, transparent) 0%, + color-mix(in oklab, var(--primary) 10%, transparent) 100% + ); + border-color: color-mix(in oklab, var(--primary) 44%, var(--edge)); + color: color-mix(in oklab, var(--primary) 78%, var(--ink)); + filter: none; + } + + .history-more-icon { + width: 14px; + height: 14px; + } + + .history-list { + display: grid; + gap: 6px; + overflow: hidden; + } + + .history-empty { + font-size: 12px; + color: var(--soft-ink); + padding: 4px 0; + } + + .history-item { + border: 1px solid var(--edge); + background: var(--panel-soft); + border-radius: 10px; + padding: 7px 10px; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + cursor: pointer; + text-align: left; + color: var(--ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .history-item:hover { + background: var(--panel); + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + background: var(--panel-strong); + border-radius: 10px; + overflow: hidden; + } + + th, + td { + padding: 6px 8px; + border-bottom: 1px solid var(--edge); + text-align: left; + } + + th { + background: var(--panel-soft); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--soft-ink); + } + + .json { + padding: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 11px; + white-space: pre-wrap; + margin: 0; + background: var(--panel-soft); + border: 1px solid var(--edge); + border-radius: 8px; + } + + @media (max-width: 900px) { + header { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + main { + padding: 20px; + } + + .list-toolbar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } + + .list-toolbar-actions { + width: 100%; + } + + .console-shell { + grid-template-columns: 1fr; + } + + .console-splitter { + display: none; + } + + .ai-panel, + .ai-form-panel { + width: 100%; + min-width: 100%; + max-width: 100%; + resize: none; + } + + .ai-panel-resizer { + display: none; + } + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } diff --git a/frontend/src/styles/console/sql-editor-parity.css b/frontend/src/styles/console/sql-editor-parity.css new file mode 100644 index 0000000..518dc27 --- /dev/null +++ b/frontend/src/styles/console/sql-editor-parity.css @@ -0,0 +1,2272 @@ +.console-shell.sql-editor-parity { + gap: 0; + grid-template-columns: minmax(220px, var(--console-left, 250px)) 1px minmax(0, 1fr); + --console-left: 250px; + --sql-editor-bg: var(--color-background-light); + --sql-editor-surface: var(--color-surface-light); + --sql-editor-surface-soft: var(--color-surface-active-light); + --sql-editor-border: var(--color-border-light); + --sql-editor-border-strong: color-mix(in oklab, var(--sql-editor-border) 88%, var(--primary) 12%); + --sql-editor-text: var(--color-text-main-light); + --sql-editor-muted: var(--color-text-muted-light); + --sql-editor-hover: color-mix(in oklab, var(--sql-editor-surface-soft) 78%, var(--primary) 22%); + --sql-editor-active: color-mix(in oklab, var(--primary) 14%, var(--sql-editor-surface)); + --sql-editor-splitter-hover: color-mix(in oklab, var(--primary) 42%, var(--sql-editor-border)); + --sql-editor-button-bg-start: color-mix(in oklab, var(--sql-editor-surface) 95%, #ffffff 5%); + --sql-editor-button-bg-end: color-mix(in oklab, var(--sql-editor-surface-soft) 84%, var(--sql-editor-surface) 16%); + --sql-editor-button-text: color-mix(in oklab, var(--sql-editor-text) 78%, var(--primary) 22%); + --sql-editor-placeholder: color-mix(in oklab, var(--sql-editor-muted) 78%, var(--sql-editor-surface) 22%); + --sql-editor-selection: color-mix(in oklab, var(--primary) 34%, transparent); + --sql-editor-line-number: color-mix(in oklab, var(--sql-editor-muted) 78%, var(--sql-editor-surface) 22%); + --sql-editor-grid-even: color-mix(in oklab, var(--sql-editor-surface-soft) 74%, var(--sql-editor-surface) 26%); + --sql-editor-execute-start: color-mix(in oklab, var(--primary) 86%, #7ec6ff 14%); + --sql-editor-execute-end: color-mix(in oklab, var(--primary) 74%, #0b4f9f 26%); + --sql-editor-execute-border: color-mix(in oklab, var(--primary) 64%, #0c4a8e 36%); + --sql-editor-execute-text: var(--primary-foreground); + --statement-tab-rail: color-mix(in oklab, var(--sql-editor-surface-soft) 90%, #eef3f9 10%); + --statement-tab-fill: color-mix(in oklab, var(--sql-editor-surface-soft) 76%, #d9e1ea 24%); + --statement-tab-fill-hover: color-mix(in oklab, var(--statement-tab-fill) 82%, #edf3fa 18%); + --statement-tab-fill-active: color-mix(in oklab, var(--statement-tab-fill) 88%, #f1f5fb 12%); + --statement-tab-divider: color-mix(in oklab, var(--sql-editor-border) 84%, #c4cfde 16%); + --statement-tab-text: color-mix(in oklab, var(--sql-editor-text) 88%, #203a66 12%); + --statement-tab-muted: color-mix(in oklab, var(--sql-editor-muted) 72%, var(--statement-tab-text) 28%); + --sql-editor-token-sql: var(--ds-mysql); + --sql-editor-token-mongo: var(--ds-mongodb); + --sql-editor-token-es: var(--ds-elasticsearch); + --sql-editor-token-method: color-mix(in oklab, var(--ds-redis) 68%, var(--ds-mysql) 32%); + --sql-editor-token-operator: var(--ds-postgresql); + --sql-editor-token-string: color-mix(in oklab, var(--ds-mongodb) 65%, #7c4a00 35%); + --sql-editor-token-number: var(--ds-postgresql); + --sql-editor-token-comment: var(--sql-editor-muted); + background: var(--sql-editor-bg); +} + +.dark .console-shell.sql-editor-parity { + --sql-editor-bg: var(--color-background-dark); + --sql-editor-surface: var(--color-surface-dark); + --sql-editor-surface-soft: var(--color-surface-active-dark); + --sql-editor-border: var(--color-border-dark); + --sql-editor-border-strong: color-mix(in oklab, var(--sql-editor-border) 80%, var(--primary) 20%); + --sql-editor-text: var(--color-text-main-dark); + --sql-editor-muted: var(--color-text-muted-dark); + --sql-editor-hover: color-mix(in oklab, var(--sql-editor-surface-soft) 78%, var(--primary) 22%); + --sql-editor-active: color-mix(in oklab, var(--primary) 22%, var(--sql-editor-surface)); + --sql-editor-splitter-hover: color-mix(in oklab, var(--primary) 48%, var(--sql-editor-border)); + --sql-editor-button-bg-start: color-mix(in oklab, var(--sql-editor-surface) 92%, #1e293b 8%); + --sql-editor-button-bg-end: color-mix(in oklab, var(--sql-editor-surface-soft) 84%, #111827 16%); + --sql-editor-button-text: color-mix(in oklab, var(--sql-editor-text) 78%, var(--primary) 22%); + --sql-editor-placeholder: color-mix(in oklab, var(--sql-editor-muted) 78%, #111827 22%); + --sql-editor-selection: color-mix(in oklab, var(--primary) 38%, transparent); + --sql-editor-line-number: color-mix(in oklab, var(--sql-editor-muted) 78%, #111827 22%); + --sql-editor-grid-even: color-mix(in oklab, var(--sql-editor-surface-soft) 80%, #111827 20%); + --sql-editor-execute-start: color-mix(in oklab, var(--primary) 76%, #93c5fd 24%); + --sql-editor-execute-end: color-mix(in oklab, var(--primary) 66%, #1d4ed8 34%); + --sql-editor-execute-border: color-mix(in oklab, var(--primary) 60%, #1e3a8a 40%); + --sql-editor-execute-text: #f8fafc; + --statement-tab-rail: color-mix(in oklab, var(--sql-editor-surface-soft) 92%, #111827 8%); + --statement-tab-fill: color-mix(in oklab, var(--sql-editor-surface-soft) 82%, #253041 18%); + --statement-tab-fill-hover: color-mix(in oklab, var(--statement-tab-fill) 86%, #364152 14%); + --statement-tab-fill-active: color-mix(in oklab, var(--statement-tab-fill) 90%, #465468 10%); + --statement-tab-divider: color-mix(in oklab, var(--sql-editor-border) 78%, #475569 22%); + --statement-tab-text: color-mix(in oklab, var(--sql-editor-text) 90%, #cddcf6 10%); + --statement-tab-muted: color-mix(in oklab, var(--sql-editor-muted) 78%, var(--statement-tab-text) 22%); +} + +.redis-proto-shell { + --sql-editor-bg: var(--color-background-light); + --sql-editor-surface: var(--color-surface-light); + --sql-editor-surface-soft: var(--color-surface-active-light); + --sql-editor-border: var(--color-border-light); + --sql-editor-border-strong: color-mix(in oklab, var(--sql-editor-border) 88%, var(--primary) 12%); + --sql-editor-text: var(--color-text-main-light); + --sql-editor-muted: var(--color-text-muted-light); + --statement-tab-rail: color-mix(in oklab, var(--sql-editor-surface-soft) 90%, #eef3f9 10%); + --statement-tab-fill: color-mix(in oklab, var(--sql-editor-surface-soft) 76%, #d9e1ea 24%); + --statement-tab-fill-hover: color-mix(in oklab, var(--statement-tab-fill) 82%, #edf3fa 18%); + --statement-tab-fill-active: color-mix(in oklab, var(--statement-tab-fill) 88%, #f1f5fb 12%); + --statement-tab-divider: color-mix(in oklab, var(--sql-editor-border) 84%, #c4cfde 16%); + --statement-tab-text: color-mix(in oklab, var(--sql-editor-text) 88%, #203a66 12%); + --statement-tab-muted: color-mix(in oklab, var(--sql-editor-muted) 72%, var(--statement-tab-text) 28%); +} + +.dark .redis-proto-shell { + --sql-editor-bg: var(--color-background-dark); + --sql-editor-surface: var(--color-surface-dark); + --sql-editor-surface-soft: var(--color-surface-active-dark); + --sql-editor-border: var(--color-border-dark); + --sql-editor-border-strong: color-mix(in oklab, var(--sql-editor-border) 80%, var(--primary) 20%); + --sql-editor-text: var(--color-text-main-dark); + --sql-editor-muted: var(--color-text-muted-dark); + --statement-tab-rail: color-mix(in oklab, var(--sql-editor-surface-soft) 92%, #111827 8%); + --statement-tab-fill: color-mix(in oklab, var(--sql-editor-surface-soft) 82%, #253041 18%); + --statement-tab-fill-hover: color-mix(in oklab, var(--statement-tab-fill) 86%, #364152 14%); + --statement-tab-fill-active: color-mix(in oklab, var(--statement-tab-fill) 90%, #465468 10%); + --statement-tab-divider: color-mix(in oklab, var(--sql-editor-border) 78%, #475569 22%); + --statement-tab-text: color-mix(in oklab, var(--sql-editor-text) 90%, #cddcf6 10%); + --statement-tab-muted: color-mix(in oklab, var(--sql-editor-muted) 78%, var(--statement-tab-text) 22%); +} + +.console-shell.sql-editor-parity, +.console-shell.sql-editor-parity .panel, +.console-shell.sql-editor-parity .panel h4 { + font-family: Inter, 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif; +} + +.console-shell.sql-editor-parity button, +.console-shell.sql-editor-parity input, +.console-shell.sql-editor-parity select, +.console-shell.sql-editor-parity textarea, +.console-shell.sql-editor-parity table, +.console-shell.sql-editor-parity code { + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.console-shell.sql-editor-parity .console-splitter { + display: block; + width: 32px; + margin-left: -15.5px; + margin-right: -15.5px; + cursor: col-resize; + background: transparent; + position: relative; + overflow: visible; + z-index: 2; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.console-shell.sql-editor-parity .console-splitter::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--sql-editor-border); +} + +.console-shell.sql-editor-parity .console-splitter:hover, +.console-shell.sql-editor-parity.resizing .console-splitter { + background: color-mix(in oklab, var(--sql-editor-splitter-hover) 10%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--sql-editor-splitter-hover) 20%, transparent); +} + +.console-shell.sql-editor-parity .console-splitter:focus-visible { + outline: none; + background: color-mix(in oklab, var(--sql-editor-splitter-hover) 12%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in oklab, var(--sql-editor-splitter-hover) 30%, transparent), + 0 0 0 3px color-mix(in oklab, var(--primary) 28%, transparent); +} + +.console-shell.sql-editor-parity .console-splitter:hover::after, +.console-shell.sql-editor-parity.resizing .console-splitter::after { + top: 0; + bottom: 0; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--sql-editor-splitter-hover); + position: absolute; + content: ''; +} + +.console-shell.sql-editor-parity .console-splitter-grip { + position: absolute; + top: 50%; + left: 50%; + width: 5px; + height: 30px; + border-radius: 999px; + transform: translate(-50%, -50%); + background: linear-gradient(180deg, + color-mix(in oklab, var(--sql-editor-border-strong) 90%, transparent) 0%, + color-mix(in oklab, var(--sql-editor-border-strong) 64%, transparent) 100%); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--sql-editor-border) 60%, transparent); + opacity: 0.42; + pointer-events: none; + transition: opacity 120ms ease, background-color 120ms ease; +} + +.console-shell.sql-editor-parity .console-splitter:hover .console-splitter-grip, +.console-shell.sql-editor-parity.resizing .console-splitter .console-splitter-grip, +.console-shell.sql-editor-parity .console-splitter:focus-visible .console-splitter-grip { + opacity: 0.9; + background: color-mix(in oklab, var(--primary) 68%, var(--sql-editor-border-strong) 32%); +} + +.console-shell.sql-editor-parity .console-panel--entities { + border: 0; + border-right: 1px solid var(--sql-editor-border); + border-radius: 0; + box-shadow: none; + background: var(--sql-editor-surface-soft); + padding: 0; +} + +.console-shell.sql-editor-parity .console-panel--entities .panel-head { + padding: 10px 42px 10px 10px; + border-bottom: 1px solid var(--sql-editor-border); + margin: 0; +} + +.entity-panel-header-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.entity-panel-header-copy { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.entity-panel-header-icon { + width: 18px; + height: 18px; + flex: 0 0 18px; + display: block; + object-fit: contain; +} + +.entity-panel-header-label { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entity-panel-header-meta { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + line-height: 1.25; + font-weight: 400; + color: var(--sql-editor-muted, var(--text-muted)); +} + +.entity-panel-refresh-button { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: color-mix(in oklab, var(--sql-editor-text, var(--ink)) 65%, transparent 35%); + box-sizing: border-box; + transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease; +} + +.entity-panel-refresh-button .material-symbols-outlined { + font-size: 18px; + line-height: 1; +} + +.entity-panel-refresh-button:hover:not(:disabled) { + color: var(--primary); + background: color-mix(in oklab, var(--primary) 8%, transparent 92%); + border-color: color-mix(in oklab, var(--primary) 30%, transparent 70%); +} + +.entity-panel-refresh-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.console-shell.sql-editor-parity .console-panel--entities .panel-head h4 { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--sql-editor-text); + letter-spacing: 0.01em; +} + +.console-shell.sql-editor-parity .console-panel--entities .panel-head-actions { + gap: 6px; +} + +.console-shell.sql-editor-parity .console-panel--entities .pill { + border: 1px solid color-mix(in oklab, var(--primary) 35%, transparent); + border-radius: 999px; + padding: 2px 8px; + color: var(--primary); + background: color-mix(in oklab, var(--primary) 8%, transparent); +} + +.console-shell.sql-editor-parity .console-panel--entities .btn.ghost.mini, +.console-shell.sql-editor-parity .console-panel--entities .btn.ghost.small { + border: 1px solid var(--sql-editor-border); + border-radius: 7px; + background: linear-gradient(180deg, var(--sql-editor-button-bg-start) 0%, var(--sql-editor-button-bg-end) 100%); + color: var(--sql-editor-button-text); + min-height: 32px; + height: 32px; + padding: 0 10px; +} + +.console-shell.sql-editor-parity .console-panel--entities #entity-pattern-label { + display: none; +} + +.console-shell.sql-editor-parity .console-panel--entities #entity-pattern { + margin: 10px 10px 0; + width: calc(100% - 20px); + height: 33px; + border-radius: 6px; + border: 1px solid var(--sql-editor-border); + padding: 0 10px; + background: var(--sql-editor-surface); + color: var(--sql-editor-text); +} + +.console-shell.sql-editor-parity .console-panel--entities #entity-pattern::placeholder { + color: var(--sql-editor-placeholder); +} + +.console-shell.sql-editor-parity .console-panel--entities #entity-pattern-hint { + margin: 8px 10px 0; + color: var(--sql-editor-muted); +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-list { + padding: 8px; + gap: 0; +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-entry { + gap: 0; +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-item { + border: 1px solid transparent; + border-radius: 6px; + min-height: 32px; + padding: 0 8px; + color: var(--sql-editor-text); + background: transparent; +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-item:hover { + background: var(--sql-editor-hover); +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-item.active { + background: var(--sql-editor-active); + border-color: color-mix(in oklab, var(--primary) 48%, var(--sql-editor-border)); +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-title { + font-weight: 600; +} + +.console-shell.sql-editor-parity .console-panel--entities .entity-expand { + border-radius: 8px; + border-color: var(--sql-editor-border); + background: var(--sql-editor-surface); +} + +.console-shell.sql-editor-parity .console-panel--entities .meta { + color: var(--sql-editor-muted); +} + +.console-panel--statement.sql-editor-parity { + padding: 0; + border: 0; + border-radius: 0; + box-shadow: none; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--sql-editor-bg); + font-size: 16px; + line-height: normal; + min-width: 0; +} + +.console-panel--statement.sql-editor-parity>* { + min-height: 0; + min-width: 0; +} + +.console-panel--statement.sql-editor-parity .console-editor-results-shell { + flex: 1; + min-height: 0; + min-width: 0; +} + +.console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + gap: 0; + grid-template-rows: + minmax(220px, max(var(--console-editor-height, 56%), var(--elastic-live-dsl-min-editor-height, 0px))) + 1px + minmax(180px, 1fr); +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter { + width: 100%; + height: 32px; + margin-top: -15.5px; + margin-bottom: -15.5px; + cursor: row-resize; + position: relative; + overflow: visible; + background: transparent; + z-index: 2; + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + transform: translateY(-50%); + background: var(--sql-editor-border); +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter:hover, +.console-panel--statement.sql-editor-parity .console-editor-results-shell.resizing .console-editor-results-splitter { + background: color-mix(in oklab, var(--sql-editor-splitter-hover) 10%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--sql-editor-splitter-hover) 20%, transparent); +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter:focus-visible { + outline: none; + background: color-mix(in oklab, var(--sql-editor-splitter-hover) 12%, transparent); + box-shadow: + inset 0 0 0 1px color-mix(in oklab, var(--sql-editor-splitter-hover) 30%, transparent), + 0 0 0 3px color-mix(in oklab, var(--primary) 28%, transparent); +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter:hover::after, +.console-panel--statement.sql-editor-parity .console-editor-results-shell.resizing .console-editor-results-splitter::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 1px; + transform: translateY(-50%); + background: var(--sql-editor-splitter-hover); +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter-grip { + position: absolute; + left: 50%; + top: 50%; + width: 38px; + height: 5px; + border-radius: 999px; + transform: translate(-50%, -50%); + background: linear-gradient(90deg, + color-mix(in oklab, var(--sql-editor-border-strong) 90%, transparent) 0%, + color-mix(in oklab, var(--sql-editor-border-strong) 64%, transparent) 100%); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--sql-editor-border) 60%, transparent); + opacity: 0.45; + pointer-events: none; + transition: opacity 120ms ease, background-color 120ms ease; +} + +.console-panel--statement.sql-editor-parity .console-editor-results-splitter:hover .console-editor-results-splitter-grip, +.console-panel--statement.sql-editor-parity .console-editor-results-shell.resizing .console-editor-results-splitter .console-editor-results-splitter-grip, +.console-panel--statement.sql-editor-parity .console-editor-results-splitter:focus-visible .console-editor-results-splitter-grip { + opacity: 0.9; + background: color-mix(in oklab, var(--primary) 68%, var(--sql-editor-border-strong) 32%); +} + +.console-panel--statement.sql-editor-parity .console-statement-panel--sql-editor { + min-height: 0; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.console-panel--statement.sql-editor-parity .console-statement-panel--sql-editor > * { + min-width: 0; +} + +.console-panel--statement.sql-editor-parity .console-results-panel { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.console-panel--statement.sql-editor-parity .console-results-panel>.console-results-content, +.console-panel--statement.sql-editor-parity .console-results-panel>.console-results-content--sql-editor { + flex: 1; + min-height: 0; + min-width: 0; +} + +.console-statement-panel--sql-editor .statement-tabs, +.redis-session-tabs-shell .statement-tabs { + margin-bottom: 0; + height: 42px; + min-height: 42px; + flex: 0 0 42px; + border-bottom: 1px solid var(--sql-editor-border); + background: transparent; + display: flex; + align-items: stretch; + gap: 0; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + box-sizing: border-box; + padding: 0; + position: relative; + z-index: 3; + isolation: isolate; +} + +.statement-tabs-viewport { + position: relative; + display: flex; + align-items: stretch; + flex: 0 1 auto; + align-self: stretch; + width: fit-content; + height: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; +} + +.statement-tabs-viewport::before, +.statement-tabs-viewport::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 18px; + pointer-events: none; + opacity: 0; + transition: opacity 120ms ease; + z-index: 1; +} + +.statement-tabs-viewport::before { + left: 0; + background: linear-gradient(90deg, var(--sql-editor-surface-soft) 0%, transparent 100%); +} + +.statement-tabs-viewport::after { + right: 0; + background: linear-gradient(270deg, var(--sql-editor-surface-soft) 0%, transparent 100%); +} + +.statement-tabs-viewport.statement-tabs-viewport--overflow-left::before, +.statement-tabs-viewport.statement-tabs-viewport--overflow-right::after { + opacity: 1; +} + +.statement-tabs-viewport.statement-tabs-viewport--overflow-left .statement-tabs-list { + padding-left: 24px; +} + +.statement-tabs-viewport.statement-tabs-viewport--overflow-right .statement-tabs-list { + padding-right: 24px; +} + +.console-statement-panel--sql-editor .statement-tabs-list, +.redis-session-tabs-shell .statement-tabs-list { + display: flex; + align-items: stretch; + gap: 0; + padding: 0; + height: 100%; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + scroll-behavior: smooth; +} + +.console-statement-panel--sql-editor .statement-tabs-list::-webkit-scrollbar, +.redis-session-tabs-shell .statement-tabs-list::-webkit-scrollbar { + display: none; +} + +.statement-tabs-scroll { + display: grid; + place-items: center; + flex: 0 0 24px; + width: 24px; + height: 100%; + padding: 0; + border: 0; + border-right: 1px solid var(--statement-tab-divider); + background: transparent; + color: var(--statement-tab-muted); + cursor: pointer; + transition: color 120ms ease, background 120ms ease, opacity 120ms ease; + z-index: 2; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + box-sizing: border-box; + align-self: stretch; +} + +.statement-tabs-scroll.statement-tabs-scroll--right { + position: absolute; + top: 0; + right: 48px; + bottom: 0; + border-left: 1px solid var(--statement-tab-divider); + border-right: 0; + z-index: 4; +} + +.statement-tabs-scroll:hover:not(:disabled) { + color: var(--primary); + background: transparent; +} + +.statement-tabs-scroll:disabled { + opacity: 1; + color: var(--statement-tab-muted); + cursor: default; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor, +.redis-session-tabs-shell .statement-tab--sql-editor { + position: relative; + min-width: 132px; + max-width: 208px; + height: 100%; + border: 0; + border-right: 1px solid var(--statement-tab-divider); + border-radius: 0; + background: transparent; + color: var(--statement-tab-muted); + display: inline-flex; + align-items: center; + justify-content: flex-start; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-weight: 600; + line-height: 1; + padding: 0 10px 0 12px; + overflow: hidden; + letter-spacing: 0; + gap: 8px; + flex: 0 0 auto; + box-shadow: none; + box-sizing: border-box; + align-self: stretch; + cursor: grab; + transition: color 120ms ease; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor:first-child, +.redis-session-tabs-shell .statement-tab--sql-editor:first-child { + border-left: 1px solid var(--statement-tab-divider); +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-content, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-content { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + height: 100%; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-datasource-icon, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-datasource-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + display: block; + object-fit: contain; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-label, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-label { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: inherit; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-close, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + margin-left: 2px; + color: var(--statement-tab-muted); + border-radius: 6px; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-close:hover, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-close:hover { + color: var(--statement-tab-text); + background: color-mix(in oklab, var(--primary) 12%, transparent); +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor .statement-tab-rename-input, +.redis-session-tabs-shell .statement-tab--sql-editor .statement-tab-rename-input { + width: 100%; + min-width: 0; + max-width: 100%; + background: color-mix(in oklab, var(--sql-editor-surface) 84%, #ffffff 16%); + border-color: color-mix(in oklab, var(--primary) 46%, var(--sql-editor-border)); + color: var(--sql-editor-text); +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor:hover, +.redis-session-tabs-shell .statement-tab--sql-editor:hover { + color: var(--statement-tab-text); + background: transparent; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor.statement-tab--dragging, +.redis-session-tabs-shell .statement-tab--sql-editor.statement-tab--dragging { + opacity: 0.74; + cursor: grabbing; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor.statement-tab--drop-before, +.redis-session-tabs-shell .statement-tab--sql-editor.statement-tab--drop-before { + box-shadow: inset 3px 0 0 var(--primary); +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor.statement-tab--drop-after, +.redis-session-tabs-shell .statement-tab--sql-editor.statement-tab--drop-after { + box-shadow: inset -3px 0 0 var(--primary); +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor.active, +.redis-session-tabs-shell .statement-tab--sql-editor.active { + color: var(--primary); + background: transparent; +} + +.console-statement-panel--sql-editor .statement-tab--sql-editor.active::after, +.redis-session-tabs-shell .statement-tab--sql-editor.active::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 2px; + background: var(--primary); + border-radius: 1px; + pointer-events: none; +} + +.console-statement-panel--sql-editor .statement-tab-dot, +.redis-session-tabs-shell .statement-tab-dot { + width: 10px; + height: 10px; + margin-left: auto; + border-radius: 999px; + background: var(--primary); + flex: 0 0 auto; +} + +.console-statement-panel--sql-editor .statement-tab-add--sql-editor, +.redis-session-tabs-shell .statement-tab-add--sql-editor { + min-width: 48px; + width: 48px; + height: 100%; + margin: 0; + padding: 0; + border-radius: 0; + border: 0; + border-left: 1px solid var(--statement-tab-divider); + border-right: 1px solid var(--statement-tab-divider); + background: transparent; + color: var(--statement-tab-muted); + font-size: 28px; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-weight: 600; + line-height: 1; + flex: 0 0 48px; + box-sizing: border-box; + align-self: stretch; + transition: color 120ms ease; +} + +.console-statement-panel--sql-editor .statement-tab-add--sql-editor:hover, +.redis-session-tabs-shell .statement-tab-add--sql-editor:hover { + color: var(--primary); + background: transparent; +} + +.console-statement-panel--sql-editor button.statement-tab-add--sql-editor, +.redis-session-tabs-shell button.statement-tab-add--sql-editor { + font-size: 28px; +} + +.editor-toolbar-sql-editor { + min-height: 41px; + flex: 0 0 auto; + border-bottom: 1px solid var(--sql-editor-border); + background: color-mix(in oklab, var(--sql-editor-surface-soft) 85%, var(--sql-editor-surface) 15%); + padding: 0 11px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: relative; + z-index: 2; +} + +.editor-toolbar-sql-editor .toolbar-left { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.editor-toolbar-sql-editor .toolbar-left::-webkit-scrollbar { + display: none; +} + +.editor-toolbar-sql-editor .toolbar-cluster { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + position: relative; + padding-right: 10px; +} + +.editor-toolbar-sql-editor .toolbar-cluster + .toolbar-cluster { + padding-left: 10px; +} + +.editor-toolbar-sql-editor .toolbar-cluster + .toolbar-cluster::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 16px; + background: color-mix(in oklab, var(--sql-editor-border) 75%, transparent); +} + +.editor-toolbar-sql-editor .toolbar-cluster:last-child { + padding-right: 0; +} + +.editor-toolbar-sql-editor .toolbar-btn-icon { + flex: 0 0 auto; + margin-right: 5px; + color: color-mix(in oklab, var(--primary) 65%, var(--sql-editor-text) 35%); +} + +.editor-toolbar-sql-editor .toolbar-btn-label { + display: inline-flex; + align-items: center; + line-height: 1; +} + +.editor-toolbar-sql-editor .toolbar-left button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + height: 32px; + border-radius: 6px; + border: 1px solid transparent; + padding: 0 10px; + margin: 0; + box-sizing: border-box; + line-height: 1; + cursor: pointer; + flex: 0 0 auto; + white-space: nowrap; + color: color-mix(in oklab, var(--sql-editor-text) 70%, transparent 30%); + background: transparent; + transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease; +} + +.editor-toolbar-sql-editor .toolbar-left button:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--primary) 30%, transparent 70%); + background: color-mix(in oklab, var(--primary) 8%, transparent 92%); + color: var(--primary); +} + +.editor-toolbar-sql-editor .toolbar-left button:hover:not(:disabled) .toolbar-btn-icon { + color: var(--primary); +} + +.editor-toolbar-sql-editor .toolbar-left button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.editor-toolbar-sql-editor .toolbar-left .execute-btn { + border-color: var(--primary); + color: var(--primary-foreground, #fff); + background: var(--primary, #4f46e5); + box-shadow: 0 1px 2px color-mix(in oklab, var(--primary, #4f46e5) 30%, transparent); +} + +.editor-toolbar-sql-editor .toolbar-left .execute-btn .toolbar-btn-icon { + color: var(--primary-foreground, #fff); +} + +.editor-toolbar-sql-editor .toolbar-left .execute-btn:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--primary) 80%, white 20%); + background: color-mix(in oklab, var(--primary, #4f46e5) 88%, white 12%); + color: var(--primary-foreground, #fff); +} + +.editor-toolbar-sql-editor .toolbar-left .execute-btn:hover:not(:disabled) .toolbar-btn-icon { + color: var(--primary-foreground, #fff); +} + +.editor-toolbar-sql-editor .d1-execution-mode { + height: 30px; + border-radius: 6px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 80%, transparent 20%); + padding: 2px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--sql-editor-muted); + background: transparent; + font-size: 11px; + font-weight: 650; + line-height: 1; + white-space: nowrap; + flex: 0 0 auto; +} + +.editor-toolbar-sql-editor .d1-execution-mode .d1-execution-option { + position: relative; + display: inline-flex; + align-items: center; + margin: 0; + cursor: pointer; +} + +.editor-toolbar-sql-editor .d1-execution-mode .d1-execution-option-text { + height: 24px; + min-width: 58px; + border-radius: 5px; + border: 1px solid transparent; + padding: 0 9px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--sql-editor-muted); + background: transparent; + transition: color 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease; +} + +.editor-toolbar-sql-editor .d1-execution-mode .d1-execution-option input[type='radio'] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +.editor-toolbar-sql-editor .d1-execution-mode .d1-execution-option input[type='radio']:checked+.d1-execution-option-text { + color: var(--primary); + border-color: transparent; + background: color-mix(in oklab, var(--primary) 12%, transparent 88%); + box-shadow: none; +} + +.editor-toolbar-sql-editor .d1-execution-mode .d1-execution-option input[type='radio']:focus-visible+.d1-execution-option-text { + outline: 2px solid color-mix(in oklab, var(--primary) 65%, white 35%); + outline-offset: 1px; +} + +.editor-toolbar-sql-editor .analyze-toggle-sql-editor { + min-height: 32px; + height: 32px; + border-radius: 7px; + border: 1px solid var(--sql-editor-border-strong); + padding: 0 9px; + margin: 0; + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--sql-editor-button-text); + background: linear-gradient(180deg, var(--sql-editor-button-bg-start) 0%, var(--sql-editor-button-bg-end) 100%); + font-size: 11px; + font-weight: 650; + line-height: 1; + white-space: nowrap; + user-select: none; + flex: 0 0 auto; +} + +.editor-toolbar-sql-editor .analyze-toggle-sql-editor input { + margin: 0; + accent-color: var(--primary); +} + +.editor-toolbar-sql-editor .toolbar-status { + display: flex; + align-items: center; + gap: 6px; + font-family: Inter, 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.02em; + color: var(--sql-editor-muted); + white-space: nowrap; + flex: 0 0 auto; +} + +.editor-toolbar-sql-editor .toolbar-status-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 70%, transparent); + background: color-mix(in oklab, var(--sql-editor-surface) 60%, transparent); + color: var(--sql-editor-muted); + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; +} + +.editor-toolbar-sql-editor .toolbar-status-icon { + flex: 0 0 auto; + opacity: 0.85; +} + +.editor-toolbar-sql-editor .toolbar-status-chip--target { + color: color-mix(in oklab, var(--sql-editor-text) 80%, var(--sql-editor-muted) 20%); + background: color-mix(in oklab, var(--primary) 6%, var(--sql-editor-surface) 94%); + border-color: color-mix(in oklab, var(--primary) 18%, var(--sql-editor-border) 82%); +} + +.editor-toolbar-sql-editor .toolbar-status-chip--engine { + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.04em; + color: color-mix(in oklab, var(--sql-editor-text) 70%, var(--primary) 30%); + background: color-mix(in oklab, var(--primary) 8%, transparent); + border-color: color-mix(in oklab, var(--primary) 22%, var(--sql-editor-border) 78%); +} + +.editor-toolbar-sql-editor .divider { + width: 1px; + height: 11px; + background: color-mix(in oklab, var(--sql-editor-border) 80%, transparent); +} + +.target-bar-sql-editor { + min-height: 44px; + flex: 0 0 auto; + padding: 6px 12px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1px; + border-bottom: 1px solid var(--sql-editor-border); + background: color-mix(in oklab, var(--sql-editor-surface-soft) 88%, transparent); + line-height: normal; +} + +.target-bar-sql-editor .target-label { + margin: 0; + font-size: 11px; + color: var(--sql-editor-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + line-height: normal; +} + +.target-bar-sql-editor .target-path { + margin: 0; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: var(--sql-editor-text); + line-height: normal; +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor { + flex: 1 1 auto; + min-height: 0; + height: 100%; + margin: 0; + border: none; + border-radius: 0; + background: color-mix(in oklab, var(--sql-editor-surface) 94%, var(--sql-editor-bg) 6%); + --statement-pad-y: 10px; + --statement-pad-x: 12px; + --statement-gutter: 30px; + position: relative; +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-monaco { + width: 100%; + height: 100%; + min-height: 0; + position: relative; +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-highlight { + position: absolute; + inset: 0; + z-index: 1; + margin: 0; + pointer-events: none; + overflow: hidden; + padding: var(--statement-pad-y) var(--statement-pad-x) var(--statement-pad-y) calc(var(--statement-pad-x) + var(--statement-gutter)); + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; + font-size: 14px; + line-height: 22px; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + color: var(--sql-editor-text); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-keyword-sql { + font-weight: 700; + color: var(--sql-editor-token-sql); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-keyword-mongo { + font-weight: 700; + color: var(--sql-editor-token-mongo); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-keyword-es { + font-weight: 700; + color: var(--sql-editor-token-es); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-method { + font-weight: 700; + color: var(--sql-editor-token-method); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-operator { + font-weight: 700; + color: var(--sql-editor-token-operator); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-string { + color: var(--sql-editor-token-string); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-number { + color: var(--sql-editor-token-number); +} + +.console-statement-panel--sql-editor .statement-shell--sql-editor .statement-token-comment { + color: var(--sql-editor-token-comment); + font-style: italic; +} + +.console-statement-panel--sql-editor .statement-line-numbers { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--statement-pad-x) + var(--statement-gutter)); + border-right: 1px solid color-mix(in oklab, var(--sql-editor-border) 72%, transparent); + pointer-events: none; + overflow: hidden; + z-index: 3; +} + +.console-statement-panel--sql-editor .statement-line-numbers-inner { + position: absolute; + inset: 0 auto auto 0; + width: 100%; + padding-top: var(--statement-pad-y); +} + +.console-statement-panel--sql-editor .statement-line-number { + height: 22px; + line-height: 22px; + text-align: right; + padding-right: 6px; + color: var(--sql-editor-line-number); + font-size: 12px; + font-family: 'JetBrains Mono', ui-monospace, Menlo, Monaco, Consolas, monospace; +} + +.console-results-content--sql-editor { + min-height: 0; + min-width: 0; + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.console-results-content--sql-editor.console-results-content--sql-editor-with-tabs { + grid-template-rows: auto auto minmax(0, 1fr) auto; +} + +.console-results-content--sql-editor .result-tabs { + margin-top: 0; + padding: 0 8px; + gap: 0; + flex-wrap: nowrap; + overflow-x: auto; + border-bottom: 1px solid var(--sql-editor-border); + background: transparent; +} + +.console-results-content--sql-editor .result-tab { + position: relative; + flex: 0 0 auto; + gap: 6px; + padding: 8px 12px 9px; + margin-bottom: -1px; + border-radius: 0; + border: none; + background: transparent; + color: color-mix(in oklab, var(--sql-editor-muted) 78%, var(--sql-editor-text) 22%); + font-weight: 500; + transition: color 120ms ease; +} + +.console-results-content--sql-editor .result-tab:hover { + background: transparent; + color: var(--sql-editor-text); +} + +.console-results-content--sql-editor .result-tab.active { + background: transparent; + color: var(--primary); + font-weight: 600; +} + +.console-results-content--sql-editor .result-tab.active::after { + content: ''; + position: absolute; + left: 12px; + right: 12px; + bottom: -1px; + height: 2px; + background: currentColor; + border-radius: 1px; +} + +.console-results-content--sql-editor .result-tab-dot { + display: none; +} + +.console-results-content--sql-editor .result-tab-label { + max-width: none; +} + +.result-header-sql-editor { + min-height: 44px; + padding: 8px 16px; + border-bottom: 1px solid var(--sql-editor-border); + background: var(--sql-editor-surface); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + line-height: normal; +} + +.result-header-sql-editor .result-header-main { + min-width: 0; +} + +.result-header-sql-editor h2 { + margin: 0 0 2px; + font-size: 13px; + font-weight: 600; + color: var(--sql-editor-text); + line-height: normal; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.01em; +} + +.result-header-sql-editor p { + margin: 0; + font-size: 12px; + color: color-mix(in oklab, var(--sql-editor-muted) 84%, var(--sql-editor-text) 16%); + line-height: normal; +} + +.result-header-sql-editor p.result-meta-success { + color: color-mix(in oklab, var(--success) 90%, var(--sql-editor-text) 10%); +} + +.result-actions-sql-editor { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: nowrap; + justify-content: flex-end; + white-space: nowrap; + overflow-x: auto; +} + +.result-actions-sql-editor button { + flex: 0 0 auto; + white-space: nowrap; + min-height: 32px; + height: 32px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 82%, var(--primary) 18%); + border-radius: 6px; + padding: 0 9px; + color: var(--sql-editor-button-text); + background: linear-gradient(180deg, var(--sql-editor-button-bg-start) 0%, var(--sql-editor-button-bg-end) 100%); + cursor: pointer; + line-height: normal; +} + +.result-actions-sql-editor button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.result-actions-sql-editor button:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--primary) 40%, var(--sql-editor-border)); + color: color-mix(in oklab, var(--sql-editor-text) 82%, var(--primary) 18%); + background: linear-gradient(180deg, + color-mix(in oklab, var(--sql-editor-button-bg-start) 92%, var(--sql-editor-surface) 8%) 0%, + color-mix(in oklab, var(--sql-editor-button-bg-end) 92%, var(--sql-editor-surface-soft) 8%) 100%); +} + +.result-filter-anchor { + position: relative; +} + +.result-filter-toolbar .result-filter-anchor { + flex: 1 1 auto; + min-width: 0; +} + +.result-filter-trigger { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + min-height: 32px; + height: 32px; + border: 1px solid color-mix(in oklab, var(--edge) 82%, var(--primary) 18%); + border-radius: var(--control-radius, 8px); + padding: 0 12px; + color: color-mix(in oklab, var(--ink) 72%, var(--primary) 28%); + background: color-mix(in oklab, var(--surface-strong) 94%, var(--primary) 6%); + cursor: pointer; + line-height: normal; + font-weight: 600; + box-shadow: 0 1px 0 color-mix(in oklab, var(--edge) 40%, transparent); + transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease, color 120ms ease; +} + +.result-filter-trigger:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.result-filter-trigger:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--primary) 52%, var(--edge) 48%); + color: color-mix(in oklab, var(--ink) 70%, var(--primary) 30%); + background: color-mix(in oklab, var(--surface-strong) 88%, var(--primary) 12%); +} + +.result-filter-trigger.is-active { + border-color: color-mix(in oklab, var(--primary) 68%, var(--edge) 32%); + color: color-mix(in oklab, var(--ink) 64%, var(--primary) 36%); + background: color-mix(in oklab, var(--surface-strong) 84%, var(--primary) 16%); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 60%, transparent); +} + +.result-filter-toolbar { + position: relative; + z-index: 5; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--sql-editor-border) 85%, transparent); + background: color-mix(in oklab, var(--sql-editor-surface-soft) 90%, var(--sql-editor-surface) 10%); +} + +.result-filter-toolbar-left { + display: flex; + align-items: flex-start; + align-content: flex-start; + gap: 8px; + min-width: 0; + flex-wrap: wrap; + overflow: visible; + padding: 1px 0; +} + +.result-filter-chip-list { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: 6px; + row-gap: 8px; +} + +.result-filter-chip-shell { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + inline-size: min(220px, 100%); + max-width: 100%; + min-width: 0; + border: 1px solid color-mix(in oklab, var(--primary) 18%, var(--sql-editor-border) 82%); + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 10%, var(--sql-editor-surface) 90%); + box-shadow: 0 1px 2px color-mix(in oklab, var(--sql-editor-bg) 14%, transparent); + padding: 2px 5px 2px 3px; +} + +.result-filter-chip-shell:hover { + border-color: color-mix(in oklab, var(--primary) 28%, var(--sql-editor-border) 72%); + background: color-mix(in oklab, var(--primary) 12%, var(--sql-editor-surface) 88%); +} + +.result-filter-chip-shell.is-editing { + border-color: color-mix(in oklab, var(--primary) 48%, var(--sql-editor-border) 52%); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 24%, transparent); + background: color-mix(in oklab, var(--primary) 16%, var(--sql-editor-surface) 84%); +} + +.result-filter-chip { + display: inline-flex; + align-items: center; + gap: 5px; + height: 25px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--sql-editor-text); + font-size: 11px; + line-height: normal; + min-width: 0; + max-width: 100%; + padding: 0 6px 0 8px; + cursor: pointer; +} + +.result-filter-chip .chip-field { + min-width: 0; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-filter-chip .chip-operator { + flex: 0 0 auto; + color: color-mix(in oklab, var(--sql-editor-muted) 75%, var(--sql-editor-text) 25%); +} + +.result-filter-chip .chip-value { + min-width: 0; + max-width: 108px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-filter-chip-remove { + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: transparent; + color: color-mix(in oklab, var(--sql-editor-muted) 70%, var(--sql-editor-text) 30%); + cursor: pointer; + line-height: 18px; + padding: 0; +} + +.result-filter-chip-remove:hover { + background: color-mix(in oklab, var(--danger) 12%, transparent); + color: color-mix(in oklab, var(--danger) 78%, var(--sql-editor-text) 22%); +} + +.result-filter-chip-hover-card { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 70; + display: inline-flex; + align-items: center; + gap: 8px; + min-width: max-content; + max-width: min(320px, calc(100vw - 40px)); + padding: 8px 10px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 72%, transparent); + border-radius: 10px; + background: #fffbea; + box-shadow: + 0 16px 28px -24px color-mix(in oklab, var(--sql-editor-bg) 70%, transparent), + 0 8px 16px -14px color-mix(in oklab, var(--sql-editor-bg) 36%, transparent); +} + +.result-filter-chip-hover-card::before { + content: ''; + position: absolute; + top: -8px; + left: 0; + right: 0; + height: 8px; +} + +.result-filter-chip-hover-text { + min-width: 0; + font-size: 11px; + color: var(--sql-editor-text); + white-space: nowrap; + overflow-wrap: break-word; + user-select: text; +} + +.result-filter-chip-hover-copy { + flex: 0 0 auto; + height: 24px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 80%, var(--primary) 20%); + border-radius: 999px; + background: color-mix(in oklab, var(--sql-editor-surface-soft) 76%, transparent); + color: color-mix(in oklab, var(--sql-editor-text) 80%, var(--primary) 20%); + font-size: 11px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + padding: 0 9px; +} + +.result-filter-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + align-self: flex-start; +} + +.result-filter-export { + flex: 0 0 auto; + white-space: nowrap; + min-height: 32px; + height: 32px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 82%, var(--primary) 18%); + border-radius: 6px; + padding: 0 9px; + color: var(--sql-editor-button-text); + background: linear-gradient(180deg, var(--sql-editor-button-bg-start) 0%, var(--sql-editor-button-bg-end) 100%); + cursor: pointer; + line-height: normal; +} + +.result-filter-export:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.result-filter-export:hover:not(:disabled) { + border-color: color-mix(in oklab, var(--primary) 40%, var(--sql-editor-border)); + color: color-mix(in oklab, var(--sql-editor-text) 82%, var(--primary) 18%); + background: linear-gradient(180deg, + color-mix(in oklab, var(--sql-editor-button-bg-start) 92%, var(--sql-editor-surface) 8%) 0%, + color-mix(in oklab, var(--sql-editor-button-bg-end) 92%, var(--sql-editor-surface-soft) 8%) 100%); +} + +.result-filter-clear { + flex: 0 0 auto; + min-height: 32px; + height: 32px; + border: 0; + border-radius: 6px; + background: transparent; + color: color-mix(in oklab, var(--sql-editor-muted) 75%, var(--sql-editor-text) 25%); + font-size: 12px; + font-weight: 600; + padding: 0 6px; + cursor: pointer; +} + +.result-filter-clear:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.result-filter-clear:hover:not(:disabled) { + color: color-mix(in oklab, var(--sql-editor-text) 78%, var(--primary) 22%); + background: color-mix(in oklab, var(--sql-editor-surface-soft) 78%, transparent); +} + +.result-filter-search { + flex: 0 0 auto; + min-height: 32px; + height: 32px; + border: 1px solid color-mix(in oklab, var(--primary) 76%, var(--sql-editor-border) 24%); + border-radius: 7px; + background: var(--primary); + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 0 12px; + cursor: pointer; + white-space: nowrap; + box-shadow: 0 1px 0 color-mix(in oklab, var(--sql-editor-bg) 74%, transparent); +} + +.result-filter-search:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.result-filter-search:hover:not(:disabled) { + background: color-mix(in oklab, var(--primary) 88%, #000 12%); +} + +.result-filter-popover { + position: fixed; + top: 0; + left: 0; + z-index: 160; + width: 280px; + max-height: min(360px, calc(100vh - 128px)); + display: flex; + flex-direction: column; + border: 1px solid color-mix(in oklab, var(--edge) 85%, var(--primary) 15%); + border-radius: 10px; + background: var(--surface-strong); + box-shadow: + 0 14px 28px -24px color-mix(in oklab, var(--ink) 42%, transparent), + 0 8px 16px -14px color-mix(in oklab, var(--ink) 24%, transparent); + padding: 10px; +} + +.result-filter-popover[data-mode='edit'] { + width: 280px; +} + +.result-filter-popover-arrow { + position: absolute; + top: -6px; + left: calc(var(--result-filter-arrow-left, 16px) - 6px); + width: 12px; + height: 12px; + background: var(--surface-strong); + border-top: 1px solid color-mix(in oklab, var(--edge) 85%, var(--primary) 15%); + border-left: 1px solid color-mix(in oklab, var(--edge) 85%, var(--primary) 15%); + transform: rotate(45deg); +} + +.result-filter-popover[data-placement='above'] .result-filter-popover-arrow { + top: auto; + bottom: -6px; + border-top: 0; + border-left: 0; + border-right: 1px solid color-mix(in oklab, var(--edge) 85%, var(--primary) 15%); + border-bottom: 1px solid color-mix(in oklab, var(--edge) 85%, var(--primary) 15%); +} + +.result-filter-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.result-filter-popover-title { + margin: 0; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: color-mix(in oklab, var(--soft-ink) 72%, var(--primary) 28%); +} + +.result-filter-popover-badge { + flex: 0 0 auto; + font-size: 10px; + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 12%, var(--surface-soft) 88%); + border: 1px solid color-mix(in oklab, var(--primary) 32%, var(--edge) 68%); + color: color-mix(in oklab, var(--ink) 62%, var(--primary) 38%); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-filter-panel-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +.result-filter-panel-fields, +.result-filter-panel-editor { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + min-width: 0; + flex: 1 1 auto; + min-height: 0; +} + +.result-filter-field-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + border-radius: 8px; + border: 1px solid var(--edge); + background: color-mix(in oklab, var(--surface-soft) 90%, var(--surface-strong) 10%); +} + +.result-filter-popover .result-filter-field-option { + width: 100%; + border: 0; + background: transparent; + color: var(--ink); + cursor: pointer; + text-align: left; + padding: 7px 8px; + border-radius: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + column-gap: 8px; + row-gap: 2px; + transition: background-color 120ms ease; +} + +.result-filter-popover .result-filter-field-option:hover { + background: color-mix(in oklab, var(--primary) 10%, transparent); +} + +.result-filter-popover .result-filter-field-option.is-selected { + background: color-mix(in oklab, var(--primary) 16%, transparent); + box-shadow: inset 3px 0 0 var(--primary); +} + +.result-filter-popover .field-option-name { + grid-column: 1; + grid-row: 1; + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-filter-popover .field-option-type { + grid-column: 1; + grid-row: 2; + font-size: 10px; + color: var(--soft-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-filter-popover .field-option-chevron { + grid-column: 2; + grid-row: 1 / span 2; + align-self: center; + color: var(--primary); + font-size: 15px; + line-height: 1; +} + +.result-filter-step-back { + display: inline-flex; + align-items: center; + gap: 6px; + align-self: flex-start; + height: 26px; + border: 1px solid var(--edge); + border-radius: 999px; + padding: 0 10px 0 8px; + background: color-mix(in oklab, var(--primary) 10%, var(--surface-soft) 90%); + color: color-mix(in oklab, var(--ink) 62%, var(--primary) 38%); + font-size: 11px; + font-weight: 600; + cursor: pointer; + max-width: 100%; + transition: border-color 120ms ease, background-color 120ms ease; +} + +.result-filter-step-back:hover { + border-color: color-mix(in oklab, var(--primary) 40%, var(--edge) 60%); + background: color-mix(in oklab, var(--primary) 16%, var(--surface-soft) 84%); +} + +.result-filter-step-back-arrow { + flex: 0 0 auto; + color: var(--primary); + font-size: 14px; + line-height: 1; +} + +.result-filter-step-back-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + +.result-filter-step-back-hint { + color: var(--soft-ink); + font-size: 10px; + font-weight: 500; + font-family: inherit; + white-space: nowrap; +} + +.result-filter-panel-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.result-filter-panel-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--soft-ink); +} + +.result-filter-panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + position: sticky; + bottom: 0; + background: var(--surface-strong); + border-top: 1px solid var(--edge); +} + +.result-filter-popover input, +.result-filter-popover select { + height: var(--control-height, 32px); + min-height: var(--control-height, 32px); + border-radius: 6px; + border: 1px solid var(--edge); + padding: 0 10px; + color: var(--ink); + background: var(--input-bg, var(--surface-strong)); + font-size: 12px; + line-height: normal; + transition: + border-color 120ms ease, + box-shadow 120ms ease, + background-color 120ms ease; +} + +.result-filter-popover input::placeholder { + color: color-mix(in oklab, var(--soft-ink) 70%, var(--edge) 30%); +} + +.result-filter-popover input:focus-visible, +.result-filter-popover select:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary) 62%, var(--edge) 38%); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 50%, transparent); +} + +.result-filter-popover input[data-testid='result-filter-field-search'] { + width: 100%; +} + +.result-filter-popover select[data-testid='result-filter-field'] { + width: 100%; +} + +.result-filter-popover select[data-testid='result-filter-field'][size] { + height: 124px; + padding: 4px; +} + +.result-filter-popover select[data-testid='result-filter-operator'] { + --result-filter-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%234f46e5' stroke-width='1.35' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + width: 100%; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + cursor: pointer; + padding-right: 30px; + border-color: var(--edge); + background-image: + var(--result-filter-select-chevron), + linear-gradient(180deg, + color-mix(in oklab, var(--surface-strong) 92%, var(--primary) 8%) 0%, + color-mix(in oklab, var(--surface-strong) 98%, var(--primary) 2%) 100%); + box-shadow: 0 1px 0 color-mix(in oklab, var(--edge) 50%, transparent); + background-repeat: no-repeat, no-repeat; + background-position: right 10px center, 0 0; + background-size: 10px 6px, 100% 100%; +} + +.result-filter-popover select[data-testid='result-filter-operator']:hover { + border-color: color-mix(in oklab, var(--primary) 40%, var(--edge) 60%); + background-image: + var(--result-filter-select-chevron), + linear-gradient(180deg, + color-mix(in oklab, var(--surface-strong) 84%, var(--primary) 16%) 0%, + color-mix(in oklab, var(--surface-strong) 94%, var(--primary) 6%) 100%); +} + +.result-filter-popover select[data-testid='result-filter-operator']:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary) 62%, var(--edge) 38%); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 50%, transparent); +} + +.result-filter-popover input[data-testid='result-filter-value'] { + width: 100%; +} + +.result-filter-panel-actions button { + flex: 0 0 auto; + min-height: var(--control-height, 32px); + height: var(--control-height, 32px); + border-radius: 6px; + padding: 0 14px; + cursor: pointer; + white-space: nowrap; + font-size: 12px; + font-weight: 600; +} + +.result-filter-cancel { + border: 0; + background: transparent; + color: var(--soft-ink); + font-weight: 600; +} + +.result-filter-cancel:hover:not(:disabled) { + color: color-mix(in oklab, var(--ink) 72%, var(--primary) 28%); + background: color-mix(in oklab, var(--surface-soft) 80%, transparent); +} + +.result-filter-apply { + border: 1px solid color-mix(in oklab, var(--primary) 80%, var(--edge) 20%); + background: linear-gradient(180deg, + color-mix(in oklab, var(--primary) 98%, #ffffff 2%) 0%, + color-mix(in oklab, var(--primary) 88%, #000000 12%) 100%); + color: #fff; + font-weight: 600; + box-shadow: 0 1px 0 color-mix(in oklab, var(--primary) 36%, transparent); +} + +.result-filter-apply:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.result-filter-apply:hover:not(:disabled) { + background: linear-gradient(180deg, + color-mix(in oklab, var(--primary) 94%, #ffffff 6%) 0%, + color-mix(in oklab, var(--primary) 82%, #000000 18%) 100%); +} + +.result-filter-apply:focus-visible { + outline: none; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 60%, transparent); +} + +.result-filter-panel-empty { + margin: 8px 0 0; + font-size: 12px; + color: var(--soft-ink); +} + +.console-results-content--sql-editor .result { + margin-top: 0; + padding: 0; + min-height: 0; + max-height: 100%; + overflow: auto; + overscroll-behavior: contain; + border-radius: 0; + border: 0; + border-top: 1px solid color-mix(in oklab, var(--sql-editor-border) 88%, var(--primary) 12%); + background: var(--sql-editor-bg); + box-shadow: inset 0 1px 0 color-mix(in oklab, var(--sql-editor-surface) 72%, transparent); +} + +.console-results-content--sql-editor .result, +.console-results-content--sql-editor .mongo-result-shell, +.console-results-content--sql-editor .result-table-shell, +.console-results-content--sql-editor .result-json, +.console-results-content--sql-editor .virtual-table-container, +.console-results-content--sql-editor .virtual-mongo-list, +.console-results-content--sql-editor .virtual-mongo-scroll, +.console-results-content--sql-editor .result-content { + min-height: 0; +} + +.console-results-content--sql-editor .result-table-shell { + margin-top: 0; + display: block; + min-height: 0; +} + +.console-results-content--sql-editor .virtual-table-container, +.console-results-content--sql-editor .virtual-mongo-scroll { + min-height: 0; +} + +.console-results-content--sql-editor .sql-editor-json-tree-wrap { + min-height: 100%; + overflow: auto; + padding: 0; + background: color-mix(in oklab, var(--sql-editor-surface-soft) 82%, transparent); +} + +.console-results-content--sql-editor .sql-editor-json-tree-row+.sql-editor-json-tree-row { + border-top: 1px solid color-mix(in oklab, var(--sql-editor-border) 74%, transparent); +} + +.console-results-content--sql-editor .row-copy-button, +.console-results-content--sql-editor .result-table-copy { + display: none !important; +} + +.console-results-content--sql-editor table { + width: 100%; + border-collapse: collapse !important; + font-size: 12px; + background: var(--sql-editor-surface); +} + +.console-results-content--sql-editor thead { + position: sticky; + top: 0; + z-index: 2; +} + +.console-results-content--sql-editor .result-table { + border-collapse: separate !important; + border-spacing: 0; +} + +.console-results-content--sql-editor .result-table thead th { + position: sticky; + top: 0; + z-index: 10; +} + +.console-results-content--sql-editor th, +.console-results-content--sql-editor td { + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 80%, transparent); + padding: 7px 8px; + text-align: left; + white-space: nowrap; +} + +.console-results-content--sql-editor th { + background: var(--sql-editor-surface-soft); + color: var(--sql-editor-muted); + font-weight: 600; + letter-spacing: 0.01em; +} + +.console-results-content--sql-editor tbody tr:nth-child(even) { + background: var(--sql-editor-grid-even); +} + +.console-results-content--sql-editor tbody tr:hover { + background: var(--sql-editor-active); +} + +.result-footer-sql-editor { + height: 34px; + border-top: 1px solid var(--sql-editor-border); + padding: 0 9px; + display: flex; + align-items: center; + justify-content: space-between; + color: var(--sql-editor-muted); + background: transparent; + line-height: normal; + font-weight: 500; +} + +.result-footer-sql-editor.result-footer-sql-editor--compact { + justify-content: flex-start; +} + +.console-results-content--sql-editor .empty-tip-sql-editor { + padding: 12px; + overflow: auto; + font-size: 12px; + color: var(--sql-editor-text); +} + +.console-results-content--sql-editor .empty-tip-sql-editor code { + border-radius: 4px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 80%, transparent); + padding: 2px 4px; + background: color-mix(in oklab, var(--sql-editor-surface) 88%, transparent); + color: var(--sql-editor-button-text); +} + +.result-footer-sql-editor .pager { + display: flex; + align-items: center; + gap: 4px; +} + +.result-footer-sql-editor .pager button { + min-width: 32px; + width: 32px; + min-height: 32px; + height: 32px; + border-radius: 5px; + border: 1px solid color-mix(in oklab, var(--sql-editor-border) 82%, var(--primary) 18%); + color: var(--sql-editor-button-text); + background: linear-gradient(180deg, var(--sql-editor-button-bg-start) 0%, var(--sql-editor-button-bg-end) 100%); + cursor: pointer; +} + +.result-footer-sql-editor .pager button.active { + border-color: var(--sql-editor-execute-border); + color: var(--sql-editor-execute-text); + background: linear-gradient(180deg, var(--sql-editor-execute-start) 0%, var(--sql-editor-execute-end) 100%); +} + +.result-footer-sql-editor .pager button:hover:not(.active) { + border-color: color-mix(in oklab, var(--primary) 40%, var(--sql-editor-border)); + color: color-mix(in oklab, var(--sql-editor-text) 82%, var(--primary) 18%); + background: linear-gradient(180deg, + color-mix(in oklab, var(--sql-editor-button-bg-start) 92%, var(--sql-editor-surface) 8%) 0%, + color-mix(in oklab, var(--sql-editor-button-bg-end) 92%, var(--sql-editor-surface-soft) 8%) 100%); +} + +@media (max-width: 760px) { + .console-panel--statement.sql-editor-parity .console-editor-results-shell.sql-editor-parity { + min-height: 520px; + grid-template-rows: + minmax(240px, 45%) + 1px + minmax(180px, 1fr); + } +} + +@media (max-width: 1380px) { + .console-shell.sql-editor-parity { + grid-template-columns: minmax(220px, var(--console-left, 250px)) 1px minmax(0, 1fr); + } +} + +@media (max-width: 1080px) { + .console-shell.sql-editor-parity { + grid-template-columns: minmax(168px, min(var(--console-left, 236px), 200px)) 1px minmax(0, 1fr); + } + + .console-shell.sql-editor-parity .console-panel--entities .panel-head { + align-items: flex-start; + flex-direction: column; + padding: 10px; + } + + .console-shell.sql-editor-parity .console-panel--entities .panel-head-actions { + width: 100%; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: 2px; + } +} + +@media (max-width: 840px) { + .console-shell.sql-editor-parity { + grid-template-columns: minmax(136px, min(var(--console-left, 210px), 150px)) 1px minmax(0, 1fr); + } + + .console-shell.sql-editor-parity .console-panel--entities .panel-head { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .console-shell.sql-editor-parity .console-panel--entities .panel-head-actions { + width: 100%; + flex-wrap: nowrap; + justify-content: flex-start; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + } + + .console-shell.sql-editor-parity .console-panel--entities .btn.ghost.mini, + .console-shell.sql-editor-parity .console-panel--entities .btn.ghost.small { + flex: 0 0 auto; + white-space: nowrap; + } +} diff --git a/frontend/src/styles/console/statement-editor.css b/frontend/src/styles/console/statement-editor.css new file mode 100644 index 0000000..9de580e --- /dev/null +++ b/frontend/src/styles/console/statement-editor.css @@ -0,0 +1,547 @@ + .statement-shell { + position: relative; + background: var(--input-bg); + border: 1px solid var(--edge); + border-radius: 10px; + --statement-pad-y: 10px; + --statement-pad-x: 12px; + --statement-gutter: 20px; + } + + .statement-tabs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + min-width: 0; + } + + .statement-tabs-list { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + overflow-x: auto; + padding: 2px 0; + scrollbar-width: none; + } + + .statement-tabs-list::-webkit-scrollbar { + display: none; + } + + .statement-tab { + border: 1px solid var(--edge); + background: var(--panel); + color: var(--soft-ink); + border-radius: 999px; + padding: 4px 12px; + font-size: 11px; + cursor: pointer; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 6px; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; + flex: 0 0 auto; + } + + .statement-tab-label { + min-width: 0; + } + + .statement-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + background: transparent; + border-radius: 999px; + font-size: 12px; + line-height: 1; + opacity: 0.66; + cursor: pointer; + flex: 0 0 32px; + transition: background 0.12s ease, opacity 0.12s ease; + } + + .statement-tab-close:hover { + background: color-mix(in oklab, var(--primary) 16%, transparent); + opacity: 1; + } + + .statement-tab-rename-input { + width: 100%; + min-width: 72px; + max-width: 180px; + border: 1px solid color-mix(in oklab, var(--primary) 42%, var(--edge)); + border-radius: 8px; + padding: 2px 8px; + font-size: 11px; + font-family: inherit; + background: var(--panel); + color: var(--ink); + } + + .statement-tab-rename-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--primary) 64%, var(--edge)); + } + + .statement-tab:hover { + border-color: color-mix(in oklab, var(--primary) 25%, var(--edge)); + color: var(--ink); + } + + .statement-tab.active { + background: color-mix(in oklab, var(--primary) 14%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + color: var(--ink); + } + + .statement-tab-add { + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px dashed var(--edge); + background: transparent; + color: var(--soft-ink); + border-radius: 999px; + width: 30px; + height: 30px; + padding: 0; + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; + } + + .statement-tab-add:hover { + background: color-mix(in oklab, var(--primary) 8%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + color: var(--ink); + } + + .statement-tab-add:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .context-menu { + position: fixed; + z-index: 120; + display: grid; + gap: 6px; + padding: 8px; + min-width: 186px; + max-width: min(240px, calc(100vw - 16px)); + border-radius: 14px; + background: + linear-gradient( + 160deg, + color-mix(in oklab, var(--panel-strong) 84%, var(--primary) 16%) 0%, + var(--panel-strong) 54% + ); + border: 1px solid color-mix(in oklab, var(--edge) 72%, var(--primary) 28%); + box-shadow: + 0 14px 34px color-mix(in oklab, #000 24%, transparent), + 0 4px 12px color-mix(in oklab, #000 14%, transparent), + inset 0 1px 0 color-mix(in oklab, #fff 32%, transparent); + backdrop-filter: saturate(1.12) blur(10px); + -webkit-backdrop-filter: saturate(1.12) blur(10px); + transform-origin: top left; + animation: context-menu-enter 130ms cubic-bezier(0.18, 0.9, 0.2, 1); + } + + .context-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + border: 1px solid transparent; + background: transparent; + color: var(--ink); + text-align: left; + cursor: pointer; + padding: 8px 10px; + min-height: 34px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.01em; + font-family: inherit; + transition: + border-color 0.14s ease, + background 0.14s ease, + color 0.14s ease, + transform 0.14s ease; + } + + .context-menu-item:hover { + background: color-mix(in oklab, var(--panel-soft) 62%, var(--primary) 38%); + border-color: color-mix(in oklab, var(--edge) 55%, var(--primary) 45%); + color: color-mix(in oklab, var(--ink) 88%, var(--primary) 12%); + transform: translateX(1px); + } + + .context-menu-item:focus-visible { + outline: none; + border-color: color-mix(in oklab, var(--primary) 58%, var(--edge)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 24%, transparent); + } + + .context-menu-item:active:not(:disabled) { + transform: translateX(0); + background: color-mix(in oklab, var(--panel-soft) 55%, var(--primary) 45%); + } + + .context-menu-item:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + } + + @keyframes context-menu-enter { + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @media (prefers-reduced-motion: reduce) { + .context-menu { + animation: none; + } + + .context-menu-item { + transition: none; + } + } + + .autocomplete-dropdown { + position: absolute; + z-index: 100; + min-width: 260px; + max-width: 420px; + max-height: 320px; + background: var(--panel-strong); + border: 1px solid var(--edge); + border-radius: 12px; + box-shadow: var(--surface-shadow); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .autocomplete-dropdown.dragging { + user-select: none; + opacity: 0.95; + } + + .autocomplete-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--edge); + background: var(--panel-soft); + cursor: grab; + } + + .autocomplete-header:active { + cursor: grabbing; + } + + .autocomplete-drag-handle { + font-size: 14px; + color: var(--soft-ink); + opacity: 0.6; + user-select: none; + } + + .autocomplete-title { + font-size: 11px; + font-weight: 600; + color: var(--ink); + text-transform: uppercase; + letter-spacing: 0.05em; + flex: 1; + } + + .autocomplete-hint { + font-size: 10px; + color: var(--soft-ink); + } + + .autocomplete-list { + overflow-y: auto; + max-height: 260px; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + } + + .autocomplete-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + cursor: pointer; + text-align: left; + font-size: 13px; + color: var(--ink); + transition: background 0.1s, border-color 0.1s; + width: 100%; + } + + .autocomplete-item:hover, + .autocomplete-item.active { + background: color-mix(in oklab, var(--primary) 8%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 25%, var(--edge)); + } + + .autocomplete-item.active { + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + } + + .autocomplete-item-icon { + font-size: 14px; + flex: 0 0 20px; + text-align: center; + } + + .autocomplete-item-label { + font-weight: 600; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + .autocomplete-item-label--sqlKeyword { + font-weight: 700; + color: var(--ds-mysql); + } + + .autocomplete-item-label--mongoOperator, + .autocomplete-item-label--dbMethod, + .autocomplete-item-label--method { + font-weight: 700; + color: var(--ds-mongodb); + } + + .autocomplete-item-label--esKeyword { + font-weight: 700; + color: var(--ds-elasticsearch); + } + + .autocomplete-item-label--snippet { + color: color-mix(in oklab, var(--soft-ink) 84%, var(--ink) 16%); + } + + .autocomplete-item-hint { + font-size: 11px; + color: var(--soft-ink); + flex: 0 0 auto; + white-space: nowrap; + } + + .dark .autocomplete-dropdown { + background: var(--panel-strong); + } + + .dark .autocomplete-header { + background: var(--panel); + } + + .dark .autocomplete-item:hover, + .dark .autocomplete-item.active { + background: rgba(255, 255, 255, 0.06); + } + + .dark .autocomplete-item.active { + background: rgba(255, 255, 255, 0.1); + } + + .statement-shell > textarea#statement-input { + position: relative; + z-index: 2; + background: transparent; + padding: var(--statement-pad-y) var(--statement-pad-x) var(--statement-pad-y) + calc(var(--statement-pad-x) + var(--statement-gutter)); + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + border: none; + outline: none; + } + + .statement-shell > .statement-highlight { + position: absolute; + inset: 0; + z-index: 1; + margin: 0; + pointer-events: none; + overflow: hidden; + padding: var(--statement-pad-y) var(--statement-pad-x) var(--statement-pad-y) + calc(var(--statement-pad-x) + var(--statement-gutter)); + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + color: var(--ink); + } + + .statement-shell > .statement-highlight .statement-token-keyword-sql { + color: var(--ds-mysql); + font-weight: 700; + } + + .statement-shell > .statement-highlight .statement-token-keyword-mongo { + color: var(--ds-mongodb); + font-weight: 700; + } + + .statement-shell > .statement-highlight .statement-token-keyword-es { + color: var(--ds-elasticsearch); + font-weight: 700; + } + + .statement-shell > .statement-highlight .statement-token-method, + .statement-shell > .statement-highlight .statement-token-operator { + color: var(--ink); + font-weight: 700; + } + + .statement-shell > .statement-highlight .statement-token-string { + color: var(--success); + } + + .statement-shell > .statement-highlight .statement-token-number { + color: var(--accent-dark); + } + + .statement-shell > .statement-highlight .statement-token-comment { + color: var(--soft-ink); + font-style: italic; + } + + .statement-gutter { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--statement-pad-x) + var(--statement-gutter)); + z-index: 4; + pointer-events: none; + overflow: hidden; + } + + .statement-gutter-inner { + position: absolute; + inset: 0; + width: 100%; + } + + .statement-runner { + pointer-events: auto; + position: absolute; + left: var(--statement-pad-x); + width: var(--statement-gutter); + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + color: var(--success); + font-size: 12px; + line-height: 1; + cursor: pointer; + opacity: 0.9; + transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease; + } + + .statement-runner:hover { + background: var(--success-bg); + border-color: color-mix(in oklab, var(--success) 40%, var(--edge)); + opacity: 1; + } + + .statement-runner:active { + transform: scale(0.96); + } + + .statement-shell > textarea#statement-input, + .statement-highlight, + .statement-ghost, + .statement-lint { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + } + + .statement-ghost { + position: absolute; + inset: 0; + padding: var(--statement-pad-y) var(--statement-pad-x) var(--statement-pad-y) + calc(var(--statement-pad-x) + var(--statement-gutter)); + line-height: 1.4; + color: transparent; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + pointer-events: none; + overflow: hidden; + z-index: 1; + } + + .statement-ghost .ghost-suffix { + color: rgba(25, 23, 15, 0.35); + } + + .statement-lint { + position: absolute; + inset: 0; + padding: var(--statement-pad-y) var(--statement-pad-x) var(--statement-pad-y) + calc(var(--statement-pad-x) + var(--statement-gutter)); + line-height: 1.4; + color: transparent; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + pointer-events: none; + overflow: hidden; + z-index: 0; + } + + .statement-lint .lint-error { + text-decoration: underline wavy #d04b1a; + text-decoration-thickness: 1.5px; + text-decoration-skip-ink: none; + } + + .lint-message { + margin-top: 6px; + font-size: 12px; + color: var(--accent-dark); + display: none; + } + + .lint-message.show { + display: block; + } diff --git a/frontend/src/styles/console/templates-redis-mongo-menu.css b/frontend/src/styles/console/templates-redis-mongo-menu.css new file mode 100644 index 0000000..2d384c1 --- /dev/null +++ b/frontend/src/styles/console/templates-redis-mongo-menu.css @@ -0,0 +1,401 @@ + .template-bar { + display: flex; + flex-wrap: wrap; + gap: 14px; + align-items: flex-end; + margin-bottom: 12px; + } + + .template-target-display { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + } + + .template-target-title { + color: var(--soft-ink); + font-weight: 600; + } + + .template-target-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + color: var(--ink); + background: var(--panel-strong); + border: 1px solid var(--edge); + border-radius: 8px; + padding: 2px 8px; + } + + .template-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .template-group .btn { + padding: 6px 10px; + font-size: 12px; + } + + .template-extra { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + + .template-extra input { + min-width: 140px; + } + + .redis-detail-card { + margin-bottom: 16px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel-soft); + display: grid; + gap: 10px; + } + + .redis-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .redis-detail-title { + font-size: 13px; + font-weight: 700; + } + + .redis-detail-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .redis-detail-body { + display: grid; + gap: 10px; + } + + .redis-inspector { + display: grid; + gap: 10px; + } + + .redis-inspector-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .redis-inspector-tab { + display: inline-flex; + align-items: center; + gap: 8px; + border-radius: 999px; + padding: 4px 10px; + font-size: 11px; + border: 1px solid var(--edge); + background: var(--panel-soft); + color: var(--soft-ink); + cursor: pointer; + max-width: 100%; + } + + .redis-inspector-tab:hover { + background: var(--panel); + color: var(--ink); + } + + .redis-inspector-tab.active { + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + color: var(--ink); + } + + .redis-inspector-tab.disabled, + .redis-inspector-tab.disabled:hover { + opacity: 0.45; + cursor: not-allowed; + background: var(--panel-soft); + color: var(--soft-ink); + } + + .redis-inspector-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--soft-ink); + flex: 0 0 auto; + } + + .redis-inspector-dot.success { + background: var(--success); + } + + .redis-inspector-dot.failed { + background: var(--danger); + } + + .redis-inspector-dot.warning { + background: rgba(234, 179, 8, 0.95); + } + + .redis-inspector-clear { + margin-left: auto; + } + + .redis-key-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .redis-key-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--soft-ink); + font-weight: 700; + } + + .redis-key-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 12px; + border-radius: 8px; + padding: 2px 8px; + border: 1px solid var(--edge); + background: #fffaf3; + } + + .redis-key-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .pill.redis-pill { + cursor: default; + background: color-mix(in oklab, var(--panel-soft) 70%, #ffffff); + } + + .redis-preview { + display: grid; + gap: 8px; + } + + .redis-command-output .result-tabs { + margin-top: 0; + } + + .redis-preview-body { + display: grid; + gap: 8px; + min-height: 120px; + max-height: 260px; + overflow-x: hidden; + overflow-y: auto; + } + + .redis-preview table { + width: 100%; + table-layout: fixed; + } + + .redis-preview th, + .redis-preview td { + overflow-wrap: anywhere; + word-break: break-word; + } + + .redis-preview-head { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + font-weight: 600; + } + + .redis-preview-actions { + display: inline-flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; + } + + .redis-preview-actions .statement-status { + max-width: 240px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .redis-string-preview { + border-radius: 10px; + border: 1px solid var(--edge); + background: #fffaf3; + padding: 10px; + } + + .redis-value { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + font-size: 12px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + } + + .redis-value--hex { + white-space: pre; + overflow-x: auto; + word-break: normal; + overflow-wrap: normal; + } + + .redis-string-chip--binary { + background: color-mix(in oklab, #b45309 14%, transparent); + color: #b45309; + border: 1px solid color-mix(in oklab, #b45309 35%, transparent); + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + } + + .redis-string-view-toggle { + display: inline-flex; + gap: 2px; + margin-left: auto; + } + + .redis-string-view-toggle .btn.is-active { + background: color-mix(in oklab, var(--primary) 14%, transparent); + color: var(--primary); + } + + .redis-command-hint { + margin-top: 8px; + padding: 6px 8px; + border-radius: 8px; + border: 1px dashed var(--edge); + background: var(--panel-soft); + font-size: 11px; + color: var(--ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + } + + .redis-command-hint.muted { + color: var(--soft-ink); + } + + .redis-result { + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel-soft); + padding: 12px; + } + + .redis-result-output { + margin: 0; + font-size: 12px; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", + monospace; + } + + .suggestions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + padding: 8px 10px; + border: 1px dashed var(--edge); + border-radius: 12px; + background: var(--panel-soft); + } + + .suggestions .meta { + width: 100%; + } + + .suggestion-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: 100%; + min-width: 0; + } + + .suggestion-actions input { + flex: 1; + min-width: 160px; + } + + .suggestions code { + padding: 2px 6px; + border-radius: 8px; + background: #fff0e1; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + } + + .mongo-field-menu { + position: fixed; + z-index: 1200; + min-width: 180px; + max-width: 240px; + background: #fffaf4; + border: 1px solid var(--edge); + border-radius: 12px; + padding: 8px; + box-shadow: 0 18px 35px rgba(25, 23, 15, 0.18); + display: none; + } + + .mongo-field-menu.open { + display: block; + } + + .mongo-field-menu-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: var(--soft-ink); + margin-bottom: 4px; + } + + .mongo-field-menu-hint, + .mongo-field-menu-empty { + font-size: 12px; + color: var(--soft-ink); + margin-bottom: 6px; + } + + .mongo-field-menu-item { + width: 100%; + border: 1px solid transparent; + background: transparent; + padding: 6px 8px; + border-radius: 8px; + text-align: left; + font-size: 12px; + cursor: pointer; + color: var(--ink); + } + + .mongo-field-menu-item:hover { + border-color: var(--accent); + background: #fff2e3; + } diff --git a/frontend/src/styles/risk-rules.css b/frontend/src/styles/risk-rules.css new file mode 100644 index 0000000..a536428 --- /dev/null +++ b/frontend/src/styles/risk-rules.css @@ -0,0 +1,1007 @@ +/* ── Risk Rules list view ──────────────────────────────── */ + +.view.active.risk-rules-view { + display: flex; + flex-direction: column; + gap: 0; + height: 100%; + overflow: hidden; +} + +/* ── Risk Rules top tabs ───────────────────────────────── */ + +.risk-rules-view .risk-tabs { + display: flex; + gap: 4px; + padding: 3px; + margin: 0 24px 14px; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--edge); + flex-shrink: 0; +} + +.risk-rules-view .risk-tabs__btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 14px; + border-radius: 8px; + border: none; + background: transparent; + font-size: 13px; + font-weight: 500; + color: var(--soft-ink); + cursor: pointer; + transition: all 0.15s; +} + +.risk-rules-view .risk-tabs__btn:hover { + color: var(--ink); + background: color-mix(in oklab, var(--primary) 5%, transparent); +} + +.risk-rules-view .risk-tabs__btn--active { + background: var(--panel); + color: var(--primary); + font-weight: 600; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); +} + +.risk-rules-view .risk-panel { + flex: 1; + overflow-y: auto; + padding: 0 24px 24px; +} + +.risk-rules-view .list-toolbar { + flex-shrink: 0; +} + +.risk-rules-view .list-controls { + flex-shrink: 0; +} + +.risk-rules-body { + flex: 1; + overflow-y: auto; + padding: 0 24px 24px; +} + +.risk-rules-section { + margin-bottom: 20px; +} + +.risk-rules-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-faint); + padding: 8px 2px 6px; +} + +/* ── Type filter tabs ─────────────────────────────────── */ + +.risk-type-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.risk-type-tab { + font-size: 12px; + font-weight: 600; + min-height: 32px; + padding: 4px 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: transparent; + color: var(--ink-faint); + cursor: pointer; + display: inline-flex; + align-items: center; + transition: all 0.15s ease; +} + +.risk-type-tab:hover { + background: var(--muted); + color: var(--ink); +} + +.risk-type-tab.active { + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); +} + +/* ── Rule card (list item) ────────────────────────────── */ + +.risk-rule-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.risk-rule-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid transparent; + transition: all 0.15s ease; + cursor: default; +} + +.risk-rule-item:hover { + background: var(--muted); + border-color: var(--edge); +} + +.risk-rule-item.dragging { + opacity: 0.5; + background: var(--muted); + border-color: var(--primary); +} + +.risk-rule-item.drag-over { + border-top: 2px solid var(--primary); +} + +.risk-rule-item--highlight { + background: color-mix(in srgb, var(--primary) 12%, transparent); + border-color: var(--primary); + animation: risk-rule-flash 2.4s ease-out; +} + +@keyframes risk-rule-flash { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 60%, transparent); } + 20% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 30%, transparent); } + 100% { box-shadow: 0 0 0 0 transparent; } +} + +.risk-rule-drag-handle { + cursor: grab; + color: var(--ink-faint); + opacity: 0.4; + transition: opacity 0.15s; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.risk-rule-drag-handle:hover { + opacity: 1; +} + +.risk-rule-drag-handle:active { + cursor: grabbing; +} + +.risk-rule-action-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.risk-rule-action-dot.action-block { background: var(--destructive, #ef4444); } +.risk-rule-action-dot.action-require_approval { background: #f97316; } +.risk-rule-action-dot.action-warn { background: #eab308; } +.risk-rule-action-dot.action-allow { background: #22c55e; } + +.risk-rule-info { + flex: 1; + min-width: 0; +} + +.risk-rule-info-top { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.risk-rule-name { + font-size: 13px; + font-weight: 600; + color: var(--ink); + white-space: normal; + overflow: visible; + text-overflow: clip; +} + +.risk-rule-code { + font-size: 10px; + font-weight: 700; + padding: 2px 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--accent); + white-space: nowrap; + flex: 0 0 auto; +} + +.risk-rule-reason { + font-size: 11.5px; + color: var(--ink-faint); + white-space: normal; + line-height: 1.4; + margin-top: 1px; +} + +.risk-rule-trigger { + font-size: 11px; + color: var(--ink-muted, var(--ink-faint)); + margin-top: 3px; + line-height: 1.4; +} + +.risk-rule-thresholds { + font-size: 11px; + color: var(--ink-faint); + margin-top: 4px; + white-space: normal; + line-height: 1.4; +} + +.risk-rule-badges { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.risk-rule-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 5px; + background: var(--muted); + color: var(--ink-faint); + white-space: nowrap; +} + +.risk-rule-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.risk-rule-actions .btn.mini { + min-height: 32px; + padding: 4px 10px; +} + +.risk-rule-readonly { + font-size: 10px; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--muted) 80%, transparent); + color: var(--ink-faint); + white-space: nowrap; +} + +/* ── Toggle switch ────────────────────────────────────── */ + +.risk-toggle { + position: relative; + width: 44px; + height: 32px; + padding: 0; + border-radius: 16px; + background: var(--muted); + border: 1px solid var(--edge); + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; + appearance: none; +} + +.risk-toggle.on { + background: var(--primary); + border-color: var(--primary); +} + +.risk-toggle::after { + content: ''; + position: absolute; + top: 5px; + left: 5px; + width: 20px; + height: 20px; + border-radius: 50%; + background: white; + transition: transform 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.risk-toggle.on::after { + transform: translateX(12px); +} + +.risk-toggle:disabled { + cursor: wait; + opacity: 0.65; +} + +/* ── Import/Export bar ────────────────────────────────── */ + +.risk-import-export { + display: flex; + gap: 6px; +} + +.risk-import-export .btn.small { + min-height: 32px; + padding: 5px 12px; +} + +/* ── Form view ────────────────────────────────────────── */ + +.view.active.risk-rules-form-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.risk-rules-form-view .list-toolbar { + flex-shrink: 0; +} + +.risk-form-body { + flex: 1; + overflow-y: auto; + padding: 0 24px 120px; +} + +.risk-form-section { + margin-bottom: 18px; +} + +.risk-form-section-title { + font-size: 13px; + font-weight: 700; + color: var(--ink); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.risk-form-section-title .section-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--primary); + color: var(--primary-foreground); + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +/* ── Action selector (big cards) ──────────────────────── */ + +.risk-action-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.risk-action-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + border-radius: 10px; + border: 2px solid var(--edge); + background: var(--panel); + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.risk-action-card:hover { + border-color: var(--primary); + background: color-mix(in oklab, var(--primary) 5%, var(--panel)); +} + +.risk-action-card.selected { + border-color: var(--primary); + background: color-mix(in oklab, var(--primary) 10%, var(--panel)); + box-shadow: 0 0 0 1px var(--primary); +} + +.risk-action-card .action-label { + font-size: 12px; + font-weight: 700; + color: var(--ink); +} + +.risk-action-card .action-desc { + font-size: 10.5px; + color: var(--ink-faint); + line-height: 1.3; +} + +@media (max-width: 640px) { + .risk-action-grid { + grid-template-columns: 1fr; + gap: 6px; + } + .risk-action-card { + flex-direction: row; + padding: 10px 12px; + gap: 8px; + text-align: left; + } + .risk-action-card .action-desc { + display: none; + } + .risk-entity-search { + flex: 1 1 100%; + } + .risk-field-row { + grid-template-columns: 1fr; + } +} + +/* ── Chip selector (multi-select) ─────────────────────── */ + +.risk-chip-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.risk-chip { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 32px; + font-size: 12px; + font-weight: 600; + padding: 5px 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: transparent; + color: var(--ink-faint); + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.risk-chip:hover { + background: var(--muted); + color: var(--ink); +} + +.risk-chip.selected { + background: color-mix(in oklab, var(--primary) 15%, var(--panel)); + color: var(--primary); + border-color: var(--primary); +} + +/* ── Form field ───────────────────────────────────────── */ + +.risk-field { + margin-bottom: 10px; +} + +.risk-field label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--ink-faint); + margin-bottom: 5px; +} + +.risk-field input[type="text"], +.risk-field input[type="number"], +.risk-field select, +.risk-field textarea { + width: 100%; + font-size: 13px; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: var(--panel); + color: var(--ink); + outline: none; + transition: border-color 0.15s; +} + +.risk-field input:focus, +.risk-field select:focus, +.risk-field textarea:focus { + border-color: var(--primary); +} + +.risk-field .hint { + font-size: 11px; + color: var(--ink-faint); + margin-top: 3px; +} + +.risk-field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +/* ── Thresholds section (collapsible) ─────────────────── */ + +.risk-thresholds-toggle { + min-height: 32px; + font-size: 12px; + font-weight: 600; + color: var(--ink-faint); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 0; + border: none; + background: none; +} + +.risk-thresholds-toggle:hover { + color: var(--ink); +} + +.risk-thresholds-body { + padding-top: 8px; +} + +/* ── Entity picker ────────────────────────────────────── */ + +.risk-entity-picker { + border: 1px solid var(--edge); + border-radius: 10px; + overflow: hidden; + max-height: 280px; + overflow-y: auto; +} + +.risk-entity-picker-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + font-size: 12px; + color: var(--ink); + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid color-mix(in oklab, var(--edge) 50%, transparent); +} + +.risk-entity-picker-item:last-child { + border-bottom: none; +} + +.risk-entity-picker-item:hover { + background: var(--muted); +} + +.risk-entity-picker-item:has(input:checked) { + background: color-mix(in oklab, var(--primary) 8%, transparent); +} + +.risk-entity-picker-item input[type="checkbox"] { + accent-color: var(--primary); +} + +.risk-entity-picker-empty { + padding: 12px; + text-align: center; + font-size: 12px; + color: var(--ink-faint); +} + +/* ── Preview card ─────────────────────────────────────── */ + +.risk-preview { + background: color-mix(in oklab, var(--muted) 50%, transparent); + border: 1px solid var(--edge); + border-radius: 10px; + padding: 12px 14px; + font-size: 12.5px; + color: var(--ink); + line-height: 1.5; +} + +.risk-preview strong { + font-weight: 700; +} + +/* ── Form actions bar ─────────────────────────────────── */ + +.risk-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 16px; + border-top: 1px solid var(--edge); + margin-top: 8px; +} + +/* ── Import/export dialog ─────────────────────────────── */ + +.risk-import-textarea { + width: 100%; + min-height: 200px; + font-family: var(--font-mono, monospace); + font-size: 12px; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: var(--panel); + color: var(--ink); + resize: vertical; +} + +/* ── Condition category groups (Redis) ────────────────── */ + +.risk-cmd-group { + margin-bottom: 10px; +} + +.risk-cmd-group-title { + font-size: 11.5px; + font-weight: 700; + color: var(--ink-faint); + margin-bottom: 6px; +} + +/* ── Custom dropdown (theme-matched) ─────────────────── */ + +.risk-dropdown { + position: relative; +} + +.risk-dropdown-trigger { + width: 100%; + min-height: 36px; + padding: 6px 10px; + border-radius: var(--control-radius, 8px); + border: 1px solid var(--edge); + background: var(--input-bg, var(--panel)); + font-family: inherit; + font-size: 13px; + color: var(--ink); + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + text-align: left; + transition: border-color 0.15s; +} + +.risk-dropdown-trigger:hover { + border-color: color-mix(in oklab, var(--primary) 40%, var(--edge)); +} + +.risk-dropdown-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.risk-dropdown-chevron { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.55; + background-image: + linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%), + linear-gradient(135deg, var(--ink-faint) 50%, transparent 50%); + background-position: center calc(50% - 1px), center calc(50% - 1px); + background-size: 5px 5px; + background-repeat: no-repeat; +} + +.risk-dropdown-menu { + position: absolute; + inset: calc(100% + 4px) 0 auto 0; + z-index: 30; + background: #fff; + border: 1px solid var(--edge); + border-radius: 10px; + padding: 4px; + box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14); + max-height: 220px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} + +.dark .risk-dropdown-menu { + background: #1e1e2e; +} + +.risk-dropdown-option { + width: 100%; + border: 1px solid transparent; + background: transparent; + border-radius: 8px; + padding: 7px 10px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + color: var(--ink); + font-family: inherit; + font-size: 13px; + text-align: left; + transition: background 0.1s; +} + +.risk-dropdown-option:hover { + background: color-mix(in oklab, var(--panel-strong, var(--muted)) 70%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 15%, var(--edge)); +} + +.risk-dropdown-option.active { + background: color-mix(in oklab, var(--primary) 10%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 25%, var(--edge)); +} + +.risk-dropdown-option-hint { + font-size: 11px; + color: var(--ink-faint); + margin-left: auto; +} + +/* ── dsType chip count badge ─────────────────────────── */ + +.risk-chip-count { + font-size: 10px; + font-weight: 700; + min-width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + border-radius: 8px; + background: var(--edge); + color: var(--ink-faint); + margin-left: 3px; + padding: 0 4px; +} + +.risk-chip.selected .risk-chip-count { + background: color-mix(in oklab, var(--primary) 30%, transparent); + color: var(--primary); +} + +/* ── Field label with action link ─────────────────────── */ + +.risk-field-label-row { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 5px; +} + +.risk-field-label-row label { + margin-bottom: 0; +} + +.risk-entity-browse-link { + display: inline-flex; + align-items: center; + min-height: 32px; + font-size: 12px; + font-weight: 600; + color: var(--primary); + background: none; + border: none; + cursor: pointer; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; + opacity: 0.85; + transition: opacity 0.15s; +} + +.risk-entity-browse-link:hover { + opacity: 1; +} + +/* ── Selected entity chips ───────────────────────────── */ + +.risk-selected-entities { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.risk-entity-chip { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 11.5px; + font-weight: 600; + padding: 3px 6px 3px 8px; + border-radius: 6px; + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + color: var(--primary); + border: 1px solid color-mix(in oklab, var(--primary) 25%, transparent); +} + +.risk-entity-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--primary); + font-size: 14px; + line-height: 1; + cursor: pointer; + padding: 0; + transition: background 0.1s; +} + +.risk-entity-chip-remove:hover { + background: color-mix(in oklab, var(--primary) 20%, transparent); +} + +.risk-entity-chip.overflow { + background: var(--muted); + color: var(--ink-faint); + border-color: var(--edge); + padding: 3px 8px; + cursor: default; +} + +.risk-entity-chip-clear { + font-size: 11px; + font-weight: 600; + color: var(--ink-faint); + background: none; + border: none; + cursor: pointer; + padding: 3px 4px; + text-decoration: underline; + text-underline-offset: 2px; +} + +.risk-entity-chip-clear:hover { + color: var(--destructive, #ef4444); +} + +/* ── Entity search ───────────────────────────────────── */ + +.risk-entity-search { + font-size: 12px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: var(--panel); + color: var(--ink); + outline: none; + transition: border-color 0.15s; +} + +.risk-entity-search:focus { + border-color: var(--primary); +} + +/* ── Entity picker list ──────────────────────────────── */ + +.risk-entity-picker-item input[type="checkbox"] { + width: 14px; + height: 14px; + border-radius: 3px; + accent-color: var(--primary); + flex-shrink: 0; +} + +.risk-entity-name { + font-size: 12.5px; + font-family: var(--font-mono, monospace); + color: var(--ink); +} + +/* ── Entity picker dialog ────────────────────────────── */ + +.risk-entity-dialog { + max-width: 520px; + width: 100%; + display: flex; + flex-direction: column; + max-height: 80vh; +} + +.risk-entity-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.risk-entity-dialog-header h3 { + margin: 0; +} + +.risk-entity-dialog-toolbar { + display: flex; + gap: 6px; + align-items: center; + margin-bottom: 8px; +} + +.risk-entity-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--edge); +} + +.risk-entity-dialog-count { + font-size: 12px; + color: var(--ink-faint); +} + +.risk-redis-specific-row { + display: flex; + gap: 8px; + align-items: center; +} + +.risk-redis-specific-row input { + flex: 1; + min-width: 0; +} + +.risk-redis-picker { + max-height: 420px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 2px; +} + +.risk-redis-picker-group { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--edge); + background: color-mix(in oklab, var(--panel) 92%, transparent); +} + +.risk-redis-picker-group-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.risk-redis-picker-group-name { + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 11px; + font-weight: 600; + color: var(--soft-ink); +} diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..038c6ad --- /dev/null +++ b/frontend/src/styles/theme.css @@ -0,0 +1,360 @@ +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Nunito Regular"), + url("../assets/fonts/nunito-v16-latin-regular.woff2") format("woff2"); +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 500 700; + font-display: swap; + src: local("Nunito Medium"), local("Nunito SemiBold"), local("Nunito Bold"), + url("../assets/fonts/nunito-v32-latin-variable.woff2") format("woff2"); +} + +html { + font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + font-size: 14px; + height: 100%; + overflow: hidden; + overscroll-behavior: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + height: 100%; + margin: 0; + overflow: hidden; + overscroll-behavior: none; +} + +h2 { + font-size: 18px; + margin: 0 0 4px; +} + +#app { + height: 100%; + position: relative; + z-index: 1; +} + +.app-shell { + min-height: 100vh; + background: transparent; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-background-light: #f8fafc; + --color-background-dark: #08071a; + --color-surface-light: #ffffff; + --color-surface-dark: #100f1e; + --color-surface-active-light: #f1f5f9; + --color-surface-active-dark: rgba(255, 255, 255, 0.06); + --color-border-light: #e2e8f0; + --color-border-dark: rgba(129, 140, 248, 0.1); + --color-text-main-light: #0f172a; + --color-text-main-dark: #f1f5f9; + --color-text-muted-light: #64748b; + --color-text-muted-dark: rgba(255, 255, 255, 0.6); + --animate-accordion-down: accordion-down 0.2s ease-out; + --animate-accordion-up: accordion-up 0.2s ease-out; + @keyframes accordion-down { + from { + height: 0; + } + to { + height: var(--reka-accordion-content-height); + } + } + @keyframes accordion-up { + from { + height: var(--reka-accordion-content-height); + } + to { + height: 0; + } + } +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: #4f46e5; + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: #4f46e5; + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --border: rgba(99, 102, 241, 0.12); + --input: rgba(99, 102, 241, 0.12); + --ring: rgba(79, 70, 229, 0.35); + --chart-1: #4f46e5; + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.5rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: #4f46e5; + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: rgba(99, 102, 241, 0.12); + --sidebar-ring: rgba(79, 70, 229, 0.35); + --ink: oklch(0.145 0 0); + --soft-ink: oklch(0.556 0 0); + --grid-line: rgba(15, 23, 42, 0.06); + --grid-line-strong: rgba(15, 23, 42, 0.12); + --control-height: 32px; + --control-radius: 8px; + --control-font: 12px; + --paper: oklch(0.995 0 0); + --paper-dark: oklch(0.985 0 0); + --paper-deep: oklch(0.96 0 0); + --accent-dark: #7c3aed; + --glow: rgba(79, 70, 229, 0.15); + --surface: rgba(255, 255, 255, 0.78); + --surface-strong: rgba(255, 255, 255, 0.94); + --surface-soft: rgba(255, 255, 255, 0.6); + --surface-border: rgba(99, 102, 241, 0.12); + --surface-shadow: 0 22px 42px rgba(15, 23, 42, 0.08); + --surface-shadow-soft: 0 12px 24px rgba(15, 23, 42, 0.06); + --edge: var(--surface-border); + --panel: var(--surface); + --panel-strong: var(--surface-strong); + --panel-soft: var(--surface-soft); + --input-bg: rgba(255, 255, 255, 0.9); + --notice-bg: rgba(255, 255, 255, 0.7); + --notice-border: rgba(15, 23, 42, 0.08); + --success: #16a34a; + --success-bg: rgba(22, 163, 74, 0.14); + --danger: #ef4444; + --danger-bg: rgba(239, 68, 68, 0.14); + --ds-mysql: #4f46e5; + --ds-postgresql: #0f766e; + --ds-mongodb: #d97706; + --ds-redis: #7c3aed; + --ds-elasticsearch: #db2777; + --ds-redis-cluster: #ca8a04; + --ds-unknown: #64748b; +} + +.dark { + --background: #08071a; + --foreground: #f1f5f9; + --card: rgba(129, 140, 248, 0.04); + --card-foreground: #f1f5f9; + --popover: #100f1e; + --popover-foreground: #f1f5f9; + --primary: #818cf8; + --primary-foreground: #0a0a0f; + --secondary: #100f1e; + --secondary-foreground: #f1f5f9; + --muted: #100f1e; + --muted-foreground: rgba(255, 255, 255, 0.6); + --accent: #818cf8; + --accent-foreground: #0a0a0f; + --destructive: oklch(0.637 0.237 25.331); + --destructive-foreground: #f1f5f9; + --border: rgba(129, 140, 248, 0.1); + --input: rgba(129, 140, 248, 0.1); + --ring: rgba(129, 140, 248, 0.45); + --chart-1: #818cf8; + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: #a78bfa; + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: #0a0918; + --sidebar-foreground: #f1f5f9; + --sidebar-primary: #818cf8; + --sidebar-primary-foreground: #f1f5f9; + --sidebar-accent: #100f1e; + --sidebar-accent-foreground: #f1f5f9; + --sidebar-border: rgba(129, 140, 248, 0.08); + --sidebar-ring: rgba(129, 140, 248, 0.45); + --ink: #f1f5f9; + --soft-ink: rgba(255, 255, 255, 0.6); + --grid-line: rgba(129, 140, 248, 0.06); + --grid-line-strong: rgba(129, 140, 248, 0.12); + --paper: #0c0b18; + --paper-dark: #08071a; + --paper-deep: #050420; + --accent-dark: #8b5cf6; + --glow: rgba(99, 102, 241, 0.15); + --surface: rgba(129, 140, 248, 0.04); + --surface-strong: rgba(12, 11, 24, 0.96); + --surface-soft: rgba(129, 140, 248, 0.03); + --surface-border: rgba(129, 140, 248, 0.1); + --surface-shadow: 0 24px 46px rgba(0, 0, 0, 0.5); + --surface-shadow-soft: 0 16px 30px rgba(0, 0, 0, 0.4); + --edge: var(--surface-border); + --panel: var(--surface); + --panel-strong: var(--surface-strong); + --panel-soft: var(--surface-soft); + --input-bg: rgba(255, 255, 255, 0.04); + --notice-bg: rgba(16, 15, 30, 0.8); + --notice-border: rgba(129, 140, 248, 0.08); + --success: #4ade80; + --success-bg: rgba(74, 222, 128, 0.18); + --danger: #f87171; + --danger-bg: rgba(248, 113, 113, 0.18); + --ds-mysql: #818cf8; + --ds-postgresql: #2dd4bf; + --ds-mongodb: #f59e0b; + --ds-redis: #c084fc; + --ds-elasticsearch: #f472b6; + --ds-redis-cluster: #facc15; + --ds-unknown: #94a3b8; +} + +@layer base { + *, + *::before, + *::after { + box-sizing: border-box; + } + * { + @apply border-border outline-ring/50; + } + body { + margin: 0; + color: var(--ink); + font-family: inherit; + min-height: 100vh; + position: relative; + isolation: isolate; + background: + radial-gradient( + 60% 50% at 10% 0%, + color-mix(in oklab, var(--primary) 14%, transparent), + transparent 70% + ), + radial-gradient( + 55% 45% at 95% 8%, + color-mix(in oklab, var(--accent-dark) 10%, transparent), + transparent 70% + ), + linear-gradient(180deg, var(--paper) 0%, var(--paper-dark) 45%, var(--paper-deep) 100%); + background-attachment: fixed; + } + + .dark body { + background: + radial-gradient( + ellipse 80% 60% at 15% 0%, + rgba(99, 102, 241, 0.18) 0%, + transparent 55% + ), + radial-gradient( + ellipse 70% 55% at 85% 10%, + rgba(139, 92, 246, 0.14) 0%, + transparent 50% + ), + radial-gradient( + ellipse 60% 50% at 50% 100%, + rgba(79, 70, 229, 0.12) 0%, + transparent 55% + ), + linear-gradient(180deg, #0c0b18 0%, #08071a 40%, #050420 100%); + background-attachment: fixed; + } + + body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 0; + background: + linear-gradient(120deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)), + repeating-linear-gradient( + 0deg, + var(--grid-line) 0, + var(--grid-line) 1px, + transparent 1px, + transparent 26px + ), + repeating-linear-gradient( + 90deg, + var(--grid-line) 0, + var(--grid-line) 1px, + transparent 1px, + transparent 26px + ); + opacity: 0.45; + } + + .dark body::before { + background: + linear-gradient(120deg, rgba(99, 102, 241, 0.04), rgba(139, 92, 246, 0.02)), + repeating-linear-gradient( + 0deg, + rgba(129, 140, 248, 0.05) 0, + rgba(129, 140, 248, 0.05) 1px, + transparent 1px, + transparent 26px + ), + repeating-linear-gradient( + 90deg, + rgba(129, 140, 248, 0.05) 0, + rgba(129, 140, 248, 0.05) 1px, + transparent 1px, + transparent 26px + ); + opacity: 0.35; + } +} diff --git a/frontend/src/styles/ui.css b/frontend/src/styles/ui.css new file mode 100644 index 0000000..7bdb4f0 --- /dev/null +++ b/frontend/src/styles/ui.css @@ -0,0 +1,8 @@ +@import "./ui/base.css"; +@import "./ui/auth.css"; +@import "./ui/buttons.css"; +@import "./ui/cards-datasource.css"; +@import "./ui/history.css"; +@import "./ui/cards-status.css"; +@import "./ui/dialogs-forms.css"; +@import "../styles/risk-rules.css"; diff --git a/frontend/src/styles/ui/auth.css b/frontend/src/styles/ui/auth.css new file mode 100644 index 0000000..edd376a --- /dev/null +++ b/frontend/src/styles/ui/auth.css @@ -0,0 +1,395 @@ + /* ── Auth gate: transparent drag region at top ── */ + .auth-drag-region { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 40px; + z-index: 100; + -webkit-app-region: drag; + } + + /* ── Auth gate: fullscreen backdrop ── */ + .auth-gate { + position: relative; + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; + background: + radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.12) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(124, 58, 237, 0.10) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(37, 99, 235, 0.08) 0%, transparent 50%), + #f0f4ff; + } + + /* ── Auth card: single centered container ── */ + .auth-card { + width: 400px; + max-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 40px 36px 36px; + border-radius: 20px; + border: 1px solid rgba(99, 102, 241, 0.1); + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.04), + 0 8px 24px rgba(0, 0, 0, 0.06), + 0 24px 48px rgba(0, 0, 0, 0.04); + } + + /* ── Back button ── */ + .auth-card__back { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px 4px 4px; + margin-bottom: 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--soft-ink); + font-size: 13px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + } + + .auth-card__back:hover { + color: var(--ink); + background: color-mix(in oklab, var(--edge) 30%, transparent); + } + + /* ── Logo ── */ + .auth-card__logo { + margin-bottom: 28px; + } + + .auth-card__logo img { + width: 52px; + height: 52px; + object-fit: contain; + border-radius: 14px; + } + + /* ── Header: title + subtitle ── */ + .auth-card__header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-align: center; + margin-bottom: 28px; + } + + .auth-card__title { + margin: 0; + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.3; + color: var(--ink); + } + + .auth-card__subtitle { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: var(--soft-ink); + max-width: 32ch; + } + + /* ── Body: buttons + inputs ── */ + .auth-card__body { + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + } + + /* ── Buttons ── */ + .auth-card__btn { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + border: none; + transition: all 0.15s ease; + } + + .auth-card__btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .auth-card__btn svg { + flex-shrink: 0; + } + + .auth-card__btn--primary { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 4px 12px rgba(79, 70, 229, 0.25); + } + + .auth-card__btn--primary:hover:not(:disabled) { + filter: brightness(1.08); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12), 0 6px 16px rgba(79, 70, 229, 0.30); + transform: translateY(-1px); + } + + .auth-card__btn--secondary { + background: color-mix(in oklab, var(--primary) 10%, var(--panel)); + color: var(--primary); + border: 1px solid color-mix(in oklab, var(--primary) 20%, var(--edge)); + } + + .auth-card__btn--secondary:hover:not(:disabled) { + background: color-mix(in oklab, var(--primary) 16%, var(--panel)); + } + + .auth-card__btn--ghost { + background: transparent; + color: var(--soft-ink); + padding: 8px 14px; + font-weight: 500; + font-size: 13px; + } + + .auth-card__btn--ghost:hover:not(:disabled) { + color: var(--ink); + background: color-mix(in oklab, var(--edge) 30%, transparent); + } + + .auth-card__cancel { + margin-top: 4px; + } + + /* ── Spinner (waiting stage) ── */ + .auth-card__spinner-ring { + width: 40px; + height: 40px; + color: var(--primary); + margin-bottom: 4px; + } + + .auth-card__spinner { + width: 100%; + height: 100%; + animation: auth-spin 1s linear infinite; + } + + @keyframes auth-spin { + to { transform: rotate(360deg); } + } + + /* ── URL box ── */ + .auth-card__url-box { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 8px; + background: color-mix(in oklab, var(--edge) 20%, transparent); + border: 1px solid color-mix(in oklab, var(--edge) 50%, transparent); + } + + .auth-card__url-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--soft-ink); + } + + .auth-card__url-link { + font-size: 11px; + line-height: 1.5; + color: var(--primary); + text-decoration: none; + word-break: break-all; + } + + .auth-card__url-link:hover { + text-decoration: underline; + } + + /* ── Divider ── */ + .auth-card__divider { + position: relative; + text-align: center; + margin: 4px 0; + } + + .auth-card__divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--edge); + } + + .auth-card__divider span { + position: relative; + padding: 0 12px; + background: var(--panel); + font-size: 11px; + color: var(--soft-ink); + line-height: 1; + } + + /* ── Manual code input ── */ + .auth-card__code-input { + display: flex; + gap: 8px; + } + + .auth-card__code-input input { + flex: 1; + min-width: 0; + padding: 9px 14px; + border-radius: 10px; + border: 1px solid var(--edge); + background: color-mix(in oklab, var(--panel) 80%, var(--surface)); + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + font-size: 15px; + letter-spacing: 3px; + text-align: center; + color: var(--ink); + outline: none; + transition: border-color 0.15s ease; + } + + .auth-card__code-input input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); + } + + .auth-card__code-input input::placeholder { + color: var(--soft-ink); + opacity: 0.4; + letter-spacing: 4px; + } + + .auth-card__code-input .auth-card__btn--secondary { + flex-shrink: 0; + width: auto; + white-space: nowrap; + } + + /* ── Error message ── */ + .auth-card__error { + width: 100%; + margin-top: 8px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.5; + color: var(--danger); + background: color-mix(in oklab, var(--danger) 8%, var(--panel)); + border: 1px solid color-mix(in oklab, var(--danger) 20%, var(--edge)); + } + + .auth-card__error svg { + flex-shrink: 0; + margin-top: 1px; + } + + /* ── Dark mode overrides ── */ + .dark .auth-gate { + background: + radial-gradient(ellipse 80% 60% at 20% 50%, rgba(99, 102, 241, 0.2) 0%, transparent 55%), + radial-gradient(ellipse 70% 55% at 80% 20%, rgba(139, 92, 246, 0.16) 0%, transparent 50%), + radial-gradient(ellipse 60% 50% at 50% 80%, rgba(79, 70, 229, 0.12) 0%, transparent 55%), + linear-gradient(180deg, #0c0b18 0%, #08071a 40%, #050420 100%); + } + + .dark .auth-card { + background: rgba(129, 140, 248, 0.04); + border-color: rgba(129, 140, 248, 0.1); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.3), + 0 8px 24px rgba(0, 0, 0, 0.4), + 0 24px 48px rgba(79, 70, 229, 0.1); + } + + .dark .auth-card__btn--primary { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2); + } + + .dark .auth-card__btn--primary:hover:not(:disabled) { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 6px 16px rgba(99, 102, 241, 0.25); + } + + .dark .auth-card__btn--secondary { + background: rgba(129, 140, 248, 0.1); + color: #818cf8; + border-color: rgba(129, 140, 248, 0.2); + } + + .dark .auth-card__btn--secondary:hover:not(:disabled) { + background: rgba(129, 140, 248, 0.16); + } + + .dark .auth-card__url-box { + background: rgba(129, 140, 248, 0.04); + border-color: rgba(129, 140, 248, 0.08); + } + + .dark .auth-card__code-input input { + background: rgba(129, 140, 248, 0.05); + border-color: rgba(129, 140, 248, 0.1); + } + + .dark .auth-card__code-input input:focus { + border-color: #818cf8; + box-shadow: 0 0 0 3px rgba(129, 140, 248, 0.25); + } + + .dark .auth-card__divider::before { + background: rgba(129, 140, 248, 0.1); + } + + .dark .auth-card__divider span { + background: rgba(129, 140, 248, 0.04); + } + + /* ── Responsive ── */ + @media (max-width: 480px) { + .auth-gate { + padding: 16px; + min-height: 100dvh; + } + + .auth-card { + padding: 32px 24px 28px; + } + + .auth-card__code-input { + flex-direction: column; + } + + .auth-card__code-input .auth-card__btn--secondary { + width: 100%; + } + } diff --git a/frontend/src/styles/ui/base.css b/frontend/src/styles/ui/base.css new file mode 100644 index 0000000..affe7ca --- /dev/null +++ b/frontend/src/styles/ui/base.css @@ -0,0 +1,109 @@ + .pill { + padding: 5px 10px; + border: 1px solid var(--edge); + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: var(--panel-strong); + cursor: pointer; + font-family: inherit; + } + + .pill-ai { + border-color: color-mix(in oklab, var(--primary) 45%, var(--edge)); + color: var(--primary); + background: color-mix(in oklab, var(--primary) 12%, var(--panel-strong)); + letter-spacing: 0.04em; + } + + .view { + display: none; + animation: fadeIn 0.3s ease; + } + + .view.active { + display: block; + } + + .notice { + margin-bottom: 16px; + padding: 12px 16px; + border-radius: 12px; + background: var(--notice-bg); + border: 1px solid var(--notice-border); + color: var(--ink); + display: none; + } + + .notice.show { + display: block; + } + + .notice.error { + border-color: rgba(208, 75, 26, 0.5); + color: var(--accent-dark); + } + + .list-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .list-toolbar-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + } + + .list-controls { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 12px; + align-items: center; + justify-content: space-between; + } + + .list-controls-left { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + flex: 1 1 420px; + min-width: 0; + } + + .list-controls-right { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; + } + + .list-controls input, + .list-controls select { + min-width: 160px; + } + + .list-controls input { + flex: 1 1 280px; + } + + .select-group { + display: flex; + align-items: center; + gap: 8px; + min-width: 200px; + } + + .select-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--soft-ink); + white-space: nowrap; + } diff --git a/frontend/src/styles/ui/buttons.css b/frontend/src/styles/ui/buttons.css new file mode 100644 index 0000000..18bb2e3 --- /dev/null +++ b/frontend/src/styles/ui/buttons.css @@ -0,0 +1,267 @@ + .btn { + border: 1px solid color-mix(in oklab, var(--primary) 40%, var(--edge)); + border-radius: var(--control-radius); + min-height: var(--control-height); + padding: 6px 14px; + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 94%, #ffffff) 0%, + color-mix(in oklab, var(--primary) 78%, var(--accent-dark) 22%) 100% + ); + color: var(--primary-foreground); + cursor: pointer; + font-weight: 600; + letter-spacing: 0.15px; + font-size: var(--control-font); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + box-shadow: 0 8px 16px rgba(37, 99, 235, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.5); + transition: transform 0.15s ease, box-shadow 0.2s ease, filter 0.2s ease; + } + + .btn:hover { + transform: translateY(-1px); + box-shadow: 0 10px 18px rgba(37, 99, 235, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.55); + filter: brightness(1.02); + } + + .btn:active { + transform: translateY(0); + box-shadow: 0 6px 12px rgba(37, 99, 235, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.45); + } + + .btn:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + + .btn.is-loading { + cursor: wait; + } + + .btn.is-loading::before { + content: ''; + width: 12px; + height: 12px; + border-radius: 999px; + border: 2px solid currentColor; + border-right-color: transparent; + animation: btn-loading-spin 0.7s linear infinite; + } + + .btn.secondary { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--panel) 92%, #ffffff) 0%, + color-mix(in oklab, var(--panel) 82%, #ffffff) 100% + ); + color: var(--ink); + border: 1px solid var(--edge); + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.08); + } + + .btn.ghost { + background: transparent; + color: var(--soft-ink); + border: 1px solid color-mix(in oklab, var(--edge) 80%, transparent); + box-shadow: none; + } + + .btn.danger { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--danger) 12%, #ffffff) 0%, + color-mix(in oklab, var(--danger) 20%, #ffffff) 100% + ); + color: #b91c1c; + border: 1px solid color-mix(in oklab, var(--danger) 35%, var(--edge)); + box-shadow: 0 4px 10px rgba(185, 28, 28, 0.1); + } + + .btn.success { + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--success) 28%, #ffffff) 0%, + color-mix(in oklab, var(--success) 70%, #16a34a) 100% + ); + color: var(--primary-foreground); + border: 1px solid color-mix(in oklab, var(--success) 45%, transparent); + box-shadow: 0 8px 14px color-mix(in oklab, var(--success) 22%, transparent); + } + + .btn.warning { + background: linear-gradient(180deg, rgba(252, 211, 77, 0.95) 0%, rgba(245, 158, 11, 0.95) 100%); + color: #1f2937; + border: 1px solid rgba(234, 179, 8, 0.55); + box-shadow: 0 8px 14px rgba(234, 179, 8, 0.2); + } + + .btn.ghost.danger { + background: transparent; + color: #b91c1c; + border: 1px solid color-mix(in oklab, var(--danger) 40%, transparent); + } + + .btn.secondary:hover, + .btn.ghost:hover, + .btn.danger:hover { + filter: brightness(1.02); + } + + .btn.secondary:hover { + background: var(--panel-strong); + } + + .btn.ghost:hover { + background: color-mix(in oklab, var(--panel-soft) 70%, transparent); + color: var(--ink); + } + + .btn.test-connection-btn { + border-color: color-mix(in oklab, var(--primary) 55%, var(--edge)); + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--primary) 14%, #ffffff) 0%, + color-mix(in oklab, var(--primary) 10%, var(--panel)) 100% + ); + color: color-mix(in oklab, var(--ink) 92%, var(--primary)); + box-shadow: 0 8px 14px rgba(37, 99, 235, 0.14); + } + + .btn.test-connection-btn:hover { + box-shadow: 0 10px 18px rgba(37, 99, 235, 0.18); + } + + .btn.ghost.danger.delete-btn { + border-style: dashed; + background: color-mix(in oklab, var(--danger) 10%, transparent); + } + + .btn.ghost.danger.delete-btn:hover { + background: color-mix(in oklab, var(--danger) 18%, transparent); + color: #991b1b; + } + +.btn.ai-toggle { + position: relative; + width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + border-radius: 999px; + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.75), + rgba(229, 236, 223, 0.55) + ); + color: var(--ink); + border: 1px solid color-mix(in oklab, var(--edge) 70%, #fff); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.12); + letter-spacing: 0.1px; + overflow: hidden; + backdrop-filter: blur(8px); +} + +.btn.ai-toggle::after { + content: ""; + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0)); + opacity: 0.7; + pointer-events: none; +} + +.btn.ai-toggle:hover { + transform: translateY(-1px); + box-shadow: 0 14px 24px rgba(15, 23, 42, 0.16); +} + +@keyframes btn-loading-spin { + to { + transform: rotate(360deg); + } +} + +/* ── Dark mode overrides ── */ + +.dark .btn { + box-shadow: 0 8px 16px rgba(99, 102, 241, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.dark .btn:hover { + box-shadow: 0 10px 18px rgba(99, 102, 241, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.dark .btn:active { + box-shadow: 0 6px 12px rgba(99, 102, 241, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.dark .btn.secondary { + background: linear-gradient( + 180deg, + rgba(129, 140, 248, 0.08) 0%, + rgba(129, 140, 248, 0.04) 100% + ); + border-color: rgba(129, 140, 248, 0.12); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); +} + +.dark .btn.secondary:hover { + background: rgba(129, 140, 248, 0.12); +} + +.dark .btn.danger { + background: linear-gradient( + 180deg, + rgba(239, 68, 68, 0.16) 0%, + rgba(239, 68, 68, 0.08) 100% + ); + color: #f87171; + border-color: rgba(239, 68, 68, 0.3); + box-shadow: 0 8px 14px rgba(239, 68, 68, 0.12); +} + +.dark .btn.ghost.danger { + color: #f87171; + border-color: rgba(239, 68, 68, 0.25); +} + +.dark .btn.ghost.danger.delete-btn:hover { + color: #fca5a5; +} + +.dark .btn.test-connection-btn { + background: linear-gradient( + 180deg, + rgba(129, 140, 248, 0.14) 0%, + rgba(129, 140, 248, 0.08) 100% + ); + box-shadow: 0 8px 14px rgba(99, 102, 241, 0.1); +} + +.dark .btn.test-connection-btn:hover { + box-shadow: 0 10px 18px rgba(99, 102, 241, 0.14); +} + +.dark .btn.ai-toggle { + background: linear-gradient( + 135deg, + rgba(129, 140, 248, 0.18), + rgba(139, 92, 246, 0.12) + ); + border-color: rgba(129, 140, 248, 0.15); + box-shadow: 0 10px 18px rgba(0, 0, 0, 0.3); +} + +.dark .btn.ai-toggle::after { + background: radial-gradient(circle, rgba(129, 140, 248, 0.2), transparent); +} + +.dark .btn.ai-toggle:hover { + box-shadow: 0 14px 24px rgba(0, 0, 0, 0.3); +} diff --git a/frontend/src/styles/ui/cards-datasource.css b/frontend/src/styles/ui/cards-datasource.css new file mode 100644 index 0000000..5640336 --- /dev/null +++ b/frontend/src/styles/ui/cards-datasource.css @@ -0,0 +1,191 @@ + .cards { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + margin: 0; + } + + .card { + background: var(--panel); + border: 1px solid var(--edge); + border-radius: 14px; + padding: 14px; + box-shadow: var(--surface-shadow); + } + + .datasource-card { + display: flex; + flex-direction: column; + gap: 10px; + } + + .datasource-card .card-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + } + + .datasource-card .card-footer { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: auto; + } + + .datasource-card .meta { + min-height: calc(2 * 1.4em + 4px); + } + + .datasource-card .datasource-type { + gap: 10px; + font-size: 13px; + font-weight: 700; + } + + .datasource-card .datasource-type::before { + display: none; + } + + .datasource-type-icon { + width: 28px; + height: 28px; + border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--edge) 82%, var(--primary) 10%); + background: color-mix(in oklab, var(--panel-strong) 70%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65), 0 10px 18px rgba(15, 23, 42, 0.08); + padding: 4px; + object-fit: contain; + } + + .endpoint-row { + display: flex; + align-items: flex-start; + gap: 8px; + min-height: calc(2 * 1.35em); + } + + .endpoint-text { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--ink); + line-height: 1.35; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow-wrap: anywhere; + } + + .copy-button { + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--edge) 70%, var(--primary) 18%); + background: linear-gradient( + 180deg, + color-mix(in oklab, var(--panel-strong) 60%, #ffffff) 0%, + var(--panel) 100% + ); + color: var(--ink); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 6px 12px rgba(15, 23, 42, 0.12); + transition: transform 120ms ease, background 120ms ease, border-color 120ms ease, box-shadow 120ms ease; + } + + .copy-button:hover { + background: var(--panel-strong); + border-color: color-mix(in oklab, var(--primary) 30%, var(--edge)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7), 0 8px 16px rgba(15, 23, 42, 0.14); + transform: translateY(-1px); + } + + .copy-button:active { + transform: translateY(0); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6), 0 4px 10px rgba(15, 23, 42, 0.12); + } + + .copy-button:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + } + + .copy-icon { + width: 16px; + height: 16px; + } + + .copy-icon-back { + fill: color-mix(in oklab, currentColor 16%, transparent); + stroke: currentColor; + stroke-width: 1.6; + opacity: 0.6; + } + + .copy-icon-front { + fill: color-mix(in oklab, currentColor 22%, transparent); + stroke: currentColor; + stroke-width: 1.6; + } + + .datasource-card .status { + margin-top: 0; + } + + .datasource-card .status-row { + margin-top: 0; + } + + .datasource-card .status.is-flash { + animation: datasource-status-shake 420ms ease-out; + will-change: transform; + } + + .datasource-card .status.connected.is-flash { + box-shadow: 0 0 0 4px rgba(46, 125, 50, 0.14); + } + + .datasource-card .status.failed.is-flash { + box-shadow: 0 0 0 4px rgba(198, 40, 40, 0.14); + } + + .datasource-card .status.is-flash:not(.connected):not(.failed) { + box-shadow: 0 0 0 4px color-mix(in oklab, var(--primary) 16%, transparent); + } + + /* ── Dark mode overrides ── */ + .dark .datasource-type-icon { + border-color: rgba(129, 140, 248, 0.12); + box-shadow: inset 0 1px 0 rgba(129, 140, 248, 0.06), 0 10px 18px rgba(0, 0, 0, 0.3); + } + + .dark .copy-button { + background: rgba(129, 140, 248, 0.06); + border-color: rgba(129, 140, 248, 0.12); + box-shadow: inset 0 1px 0 rgba(129, 140, 248, 0.06), 0 6px 12px rgba(0, 0, 0, 0.3); + } + + .dark .copy-button:hover { + background: rgba(129, 140, 248, 0.1); + box-shadow: inset 0 1px 0 rgba(129, 140, 248, 0.08), 0 8px 16px rgba(0, 0, 0, 0.35); + } + + .dark .copy-button:active { + box-shadow: inset 0 1px 0 rgba(129, 140, 248, 0.06), 0 4px 10px rgba(0, 0, 0, 0.3); + } + + @keyframes datasource-status-shake { + 0% { transform: translateX(0); } + 18% { transform: translateX(-2px); } + 36% { transform: translateX(2px); } + 54% { transform: translateX(-1px); } + 72% { transform: translateX(1px); } + 100% { transform: translateX(0); } + } diff --git a/frontend/src/styles/ui/cards-status.css b/frontend/src/styles/ui/cards-status.css new file mode 100644 index 0000000..455d20e --- /dev/null +++ b/frontend/src/styles/ui/cards-status.css @@ -0,0 +1,166 @@ + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .card-header h3 { + margin: 0; + } + + .card h3 { + margin: 0 0 6px; + font-size: 16px; + } + + .meta { + font-size: 12px; + color: color-mix(in oklab, var(--ink) 65%, var(--soft-ink)); + display: grid; + gap: 4px; + } + + .status { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 12px; + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 10%, var(--panel-strong)); + border: 1px solid var(--edge); + color: var(--ink); + } + + .status-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + } + + .status-detail-row { + display: flex; + align-items: center; + gap: 8px; + min-height: calc(1.35em + 2px); + margin-top: 6px; + } + + .status-detail-left { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + } + + .status-detail-text { + flex: 1; + min-width: 0; + font-size: 12px; + color: #8e1b1b; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .status-meta { + margin-top: 6px; + font-size: 11px; + color: var(--soft-ink); + } + + .status-meta-inline { + margin-top: 0; + white-space: nowrap; + } + + .status-row-bottom { + margin-top: 6px; + } + + .metrics-row { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 2px; + font-size: 12px; + } + + .metrics-label { + color: var(--soft-ink); + flex-shrink: 0; + } + + .metrics-text { + color: var(--ink); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .status.connected { + background: #e8f5e9; + border-color: #2e7d32; + color: #1b5e20; + } + + .status.failed { + background: #fdecea; + border-color: #c62828; + color: #8e1b1b; + } + + .card.status-connected { + border-color: #2e7d32; + box-shadow: 0 10px 30px rgba(46, 125, 50, 0.18); + } + + .card.status-failed { + border-color: #c62828; + box-shadow: 0 10px 30px rgba(198, 40, 40, 0.18); + } + + .actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; + } + + .actions.actions-tight { + gap: 6px; + } + + /* ── Dark mode overrides ── */ + .dark .status.connected { + background: rgba(74, 222, 128, 0.1); + border-color: rgba(74, 222, 128, 0.3); + color: #4ade80; + } + + .dark .status.failed { + background: rgba(248, 113, 113, 0.1); + border-color: rgba(248, 113, 113, 0.3); + color: #f87171; + } + + .dark .status-detail-text { + color: #f87171; + } + + .dark .card.status-connected { + border-color: rgba(74, 222, 128, 0.25); + box-shadow: 0 10px 30px rgba(74, 222, 128, 0.06), 0 0 0 1px rgba(129, 140, 248, 0.05); + } + + .dark .card.status-failed { + border-color: rgba(248, 113, 113, 0.25); + box-shadow: 0 10px 30px rgba(248, 113, 113, 0.06), 0 0 0 1px rgba(129, 140, 248, 0.05); + } diff --git a/frontend/src/styles/ui/dialogs-forms.css b/frontend/src/styles/ui/dialogs-forms.css new file mode 100644 index 0000000..49becb8 --- /dev/null +++ b/frontend/src/styles/ui/dialogs-forms.css @@ -0,0 +1,810 @@ + .dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(10, 14, 23, 0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + display: grid; + place-items: center; + z-index: 50; + padding: 16px; + animation: dialog-fade-in 0.2s ease; + } + + @keyframes dialog-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes dialog-slide-up { + from { opacity: 0; transform: translateY(12px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } + } + + .dialog-card { + width: min(440px, 92vw); + background: var(--panel-strong, var(--panel)); + border: 1px solid var(--edge); + border-radius: 18px; + padding: 22px; + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.08), + 0 24px 60px rgba(0, 0, 0, 0.2); + display: grid; + gap: 16px; + animation: dialog-slide-up 0.25s ease; + } + + .dialog-card--scrollable { + max-height: calc(100vh - 32px); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .dialog-backdrop--results { + padding: 12px; + } + + .dialog-card--results { + width: min(1280px, calc(100vw - 24px)); + height: min(940px, calc(100vh - 24px)); + min-width: min(720px, calc(100vw - 24px)); + min-height: min(520px, calc(100vh - 24px)); + padding: 14px; + display: flex; + flex-direction: column; + background: var(--paper); + border: 1px solid color-mix(in oklab, var(--edge) 62%, var(--ink)); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.35); + overflow: hidden; + resize: both; + } + + .dialog-card--results .dialog-head { + cursor: grab; + user-select: none; + } + + .dialog-card--results .dialog-head:active { + cursor: grabbing; + } + + .dialog-body { + min-height: 0; + } + + .dialog-body--results { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .dialog-card h4 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--ink); + } + + .dialog-head .meta { + margin-top: 2px; + } + + .dialog-card--danger { + width: min(420px, 92vw); + padding: 20px; + gap: 14px; + border-color: color-mix(in oklab, var(--danger) 18%, var(--edge)); + background: var(--panel-strong, var(--panel)); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.08), + 0 24px 60px rgba(0, 0, 0, 0.18); + } + + .dialog-card--warning { + width: min(420px, 92vw); + padding: 20px; + gap: 14px; + border-color: color-mix(in oklab, var(--warning, #d97706) 18%, var(--edge)); + background: var(--panel-strong, var(--panel)); + box-shadow: + 0 4px 12px rgba(0, 0, 0, 0.08), + 0 24px 60px rgba(0, 0, 0, 0.18); + } + + .dialog-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .dialog-head-main { + display: flex; + align-items: flex-start; + gap: 12px; + } + + .dialog-icon { + width: 38px; + height: 38px; + border-radius: 11px; + display: grid; + place-items: center; + font-weight: 700; + font-size: 15px; + letter-spacing: 0.02em; + background: color-mix(in oklab, var(--primary) 12%, var(--surface, var(--panel))); + color: var(--primary); + border: 1px solid color-mix(in oklab, var(--primary) 18%, var(--edge)); + flex-shrink: 0; + } + + .dialog-icon.danger { + background: color-mix(in oklab, var(--danger) 10%, var(--panel)); + color: var(--danger, #b91c1c); + border: 1px solid color-mix(in oklab, var(--danger) 20%, var(--edge)); + } + + .dialog-icon.warning { + background: color-mix(in oklab, var(--warning, #d97706) 10%, var(--panel)); + color: var(--warning, #d97706); + border: 1px solid color-mix(in oklab, var(--warning, #d97706) 20%, var(--edge)); + } + + .pill-danger { + border-color: color-mix(in oklab, var(--danger) 25%, var(--edge)); + color: #b91c1c; + background: color-mix(in oklab, var(--danger) 8%, var(--panel-strong)); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + } + + .pill-warning { + border-color: color-mix(in oklab, var(--warning, #d97706) 25%, var(--edge)); + color: #d97706; + background: color-mix(in oklab, var(--warning, #d97706) 8%, var(--panel-strong)); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + } + + .dialog-command { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + line-height: 1.5; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--edge); + background: color-mix(in oklab, var(--panel-soft, var(--panel)) 70%, var(--paper, #fff)); + color: var(--ink); + word-break: break-word; + white-space: pre-wrap; + max-height: 160px; + overflow: auto; + } + + .dialog-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel-strong); + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + max-height: min(52vh, 420px); + overflow: auto; + } + + .dialog-stages { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--soft-ink); + } + + .dialog-stages svg { + flex-shrink: 0; + opacity: 0.5; + } + + .dialog-highlight { + padding: 10px 14px; + border-radius: 12px; + border: 1px dashed color-mix(in oklab, var(--danger) 28%, var(--edge)); + background: color-mix(in oklab, var(--danger) 6%, var(--panel)); + color: var(--ink); + font-weight: 600; + font-size: 13px; + word-break: break-word; + } + + .dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 4px; + border-top: 1px solid color-mix(in oklab, var(--edge) 50%, transparent); + } + + .form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 16px; + margin-bottom: 18px; + } + + .form-grid .span-2 { + grid-column: 1 / -1; + } + + .install-details { + margin-top: 10px; + padding: 10px 12px; + border-radius: 12px; + border: 1px dashed var(--edge); + background: var(--panel-soft); + } + + .install-details > summary { + cursor: pointer; + font-weight: 700; + color: var(--ink); + list-style: none; + } + + .install-details > summary::-webkit-details-marker { + display: none; + } + + .install-grid { + margin-top: 10px; + display: grid; + gap: 12px; + } + + .install-block { + display: grid; + gap: 6px; + } + + .install-block .btn.mini { + justify-self: start; + min-height: 32px; + padding: 5px 10px; + } + + .inline-row { + display: flex; + align-items: center; + gap: 8px; + } + + .ai-provider-line { + font-size: 13px; + color: var(--soft-ink); + } + + .provider-warning { + margin-top: 6px; + font-size: 12px; + color: #b3261e; + } + + .ai-provider-row { + flex: 1; + } + + .ai-provider-row select { + flex: 1; + min-width: 0; + } + + #ds-ai-provider-clear:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .ai-provider-meta { + margin-top: 6px; + } + + .btn.mini { + width: auto; + min-height: 24px; + padding: 3px 8px; + border-radius: var(--control-radius); + font-size: 11px; + box-shadow: none; + } + + .stack { + margin-top: 4px; + display: grid; + gap: 14px; + } + + .dialog-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 2px 0; + } + + .form-errors { + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(208, 75, 26, 0.4); + background: rgba(208, 75, 26, 0.08); + color: var(--accent-dark); + font-size: 13px; + display: none; + } + + .form-errors.show { + display: block; + } + + .field-error { + border-color: rgba(208, 75, 26, 0.6); + background: #fff3ee; + } + + label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--soft-ink); + margin-bottom: 6px; + letter-spacing: 0.01em; + } + + input, + select, + textarea { + width: 100%; + min-height: var(--control-height); + padding: 8px 12px; + border-radius: var(--control-radius); + border: 1px solid var(--edge); + background: var(--input-bg); + font-family: inherit; + font-size: var(--control-font); + color: var(--ink); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + } + + input:focus, + select:focus, + textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 12%, transparent); + } + + select { + cursor: pointer; + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, color-mix(in oklab, var(--ink) 55%, var(--soft-ink)) 50%), + linear-gradient(135deg, color-mix(in oklab, var(--ink) 55%, var(--soft-ink)) 50%, transparent 50%); + background-position: + calc(100% - 16px) calc(50% - 2px), + calc(100% - 11px) calc(50% - 2px); + background-size: 5px 5px; + background-repeat: no-repeat; + padding-right: 30px; + } + + .ds-type-select { + position: relative; + } + + .ds-type-select-trigger { + width: 100%; + min-height: var(--control-height); + padding: 6px 10px; + border-radius: var(--control-radius); + border: 1px solid var(--edge); + background: var(--input-bg); + font-family: inherit; + font-size: var(--control-font); + color: var(--ink); + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + text-align: left; + } + + .ds-type-select-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .ds-type-select-trigger.field-error { + border-color: rgba(208, 75, 26, 0.6); + background: #fff3ee; + } + + .ds-type-select-trigger-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + object-fit: contain; + } + + .ds-type-select-trigger-label { + flex: 1; + min-width: 0; + } + + .ds-type-select-chevron { + width: 10px; + height: 10px; + flex-shrink: 0; + opacity: 0.7; + background-image: + linear-gradient(45deg, transparent 50%, color-mix(in oklab, var(--ink) 55%, var(--soft-ink)) 50%), + linear-gradient(135deg, color-mix(in oklab, var(--ink) 55%, var(--soft-ink)) 50%, transparent 50%); + background-position: + center calc(50% - 1px), + center calc(50% - 1px); + background-size: 5px 5px; + background-repeat: no-repeat; + } + + .ds-type-select-menu { + position: absolute; + inset: calc(100% + 6px) 0 auto 0; + z-index: 30; + background: var(--panel); + border: 1px solid var(--edge); + border-radius: 12px; + padding: 6px; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.14); + display: grid; + gap: 4px; + } + + .ds-type-select-option { + width: 100%; + border: 1px solid transparent; + background: transparent; + border-radius: 10px; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + color: var(--ink); + font-family: inherit; + font-size: var(--control-font); + text-align: left; + } + + .ds-type-select-option:hover { + background: color-mix(in oklab, var(--panel-strong) 70%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 22%, var(--edge)); + } + + .ds-type-select-option[aria-selected="true"] { + background: color-mix(in oklab, var(--primary) 10%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 28%, var(--edge)); + } + + .ds-type-select-option-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + object-fit: contain; + } + + input[type="checkbox"] { + width: 16px; + height: 16px; + min-height: 16px; + padding: 0; + margin: 0; + vertical-align: middle; + flex: 0 0 auto; + } + + .checkbox-inline-label { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0; + color: var(--soft-ink); + font-size: 12px; + line-height: 1.35; + } + + .checkbox-inline-label input[type="checkbox"] { + margin: 0; + } + + .dynamo-config-path-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + } + + .dynamo-config-path-row input { + flex: 1 1 auto; + min-width: 0; + } + + .dynamo-config-path-row .btn { + flex: 0 0 auto; + white-space: nowrap; + margin-top: 0; + } + + .dynamo-sensitive-inline { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + } + + .dynamo-sensitive-inline input { + flex: 1 1 auto; + min-width: 0; + } + + .dynamo-sensitive-inline .btn { + flex: 0 0 auto; + white-space: nowrap; + margin-top: 0; + } + + .password-toggle-wrapper { + position: relative; + } + + .password-toggle-wrapper input { + width: 100%; + padding-right: 36px; + } + + .password-toggle-btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + padding: 2px; + color: var(--fg-muted, #888); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; + transition: opacity 0.15s; + } + + .password-toggle-btn:hover { + opacity: 1; + } + + .dynamo-sensitive-token { + position: relative; + } + + .dynamo-sensitive-token textarea { + padding-right: 72px; + } + + .dynamo-sensitive-token-copy { + position: absolute; + top: 8px; + right: 8px; + margin-top: 0; + z-index: 1; + } + + .pg-cert-meta.pg-cert-meta-success { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid #2e7d32; + background: #e8f5e9; + color: #1b5e20; + } + + .pg-cert-link { + color: #1b5e20; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 2px; + white-space: nowrap; + } + + .pg-cert-link:hover, + .pg-cert-link:focus-visible { + color: #155421; + } + + textarea { + min-height: 96px; + resize: vertical; + line-height: 1.4; + } + + .toggle-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + } + + .toggle-row label { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + color: var(--ink); + font-size: 13px; + } + + .test-connection-block { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + max-width: min(720px, 100%); + } + + .test-connection-status { + display: flex; + flex-direction: column; + gap: 4px; + } + + .test-connection-status .status { + margin-top: 0; + } + + .test-connection-detail { + font-size: 12px; + line-height: 1.35; + color: var(--soft-ink); + word-break: break-word; + } + + .test-connection-status.connected .test-connection-detail { + color: #1b5e20; + } + + .test-connection-status.failed .test-connection-detail { + color: #8e1b1b; + } + + .d1-oauth-warning { + color: #8e1b1b; + } + + /* ── Dark mode overrides ── */ + .dark .dialog-backdrop { + background: rgba(5, 4, 20, 0.6); + } + + .dark .dialog-card { + background: var(--popover, #100f1e); + border-color: rgba(129, 140, 248, 0.12); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.3), + 0 24px 60px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(129, 140, 248, 0.06); + } + + .dark .dialog-card--danger { + border-color: rgba(248, 113, 113, 0.12); + background: var(--popover, #100f1e); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.3), + 0 24px 60px rgba(0, 0, 0, 0.4), + inset 0 0 0 1px rgba(248, 113, 113, 0.06); + } + + .dark .dialog-card--warning { + border-color: rgba(217, 119, 6, 0.12); + background: var(--popover, #100f1e); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.3), + 0 24px 60px rgba(0, 0, 0, 0.4), + inset 0 0 0 1px rgba(217, 119, 6, 0.06); + } + + .dark .dialog-icon { + background: color-mix(in oklab, var(--primary) 14%, transparent); + border-color: rgba(129, 140, 248, 0.15); + } + + .dark .dialog-icon.danger { + background: rgba(248, 113, 113, 0.12); + color: #f87171; + border-color: rgba(248, 113, 113, 0.2); + } + + .dark .dialog-icon.warning { + background: rgba(217, 119, 6, 0.12); + color: #fbbf24; + border-color: rgba(217, 119, 6, 0.2); + } + + .dark .dialog-highlight { + border-color: rgba(248, 113, 113, 0.2); + background: rgba(248, 113, 113, 0.06); + } + + .dark .pill-danger { + color: #f87171; + border-color: rgba(248, 113, 113, 0.25); + background: rgba(248, 113, 113, 0.1); + } + + .dark .pill-warning { + color: #fbbf24; + border-color: rgba(217, 119, 6, 0.25); + background: rgba(217, 119, 6, 0.1); + } + + .dark .dialog-actions { + border-top-color: rgba(129, 140, 248, 0.08); + } + + .dark .dialog-command { + border-color: rgba(129, 140, 248, 0.12); + background: rgba(255, 255, 255, 0.03); + color: var(--ink); + } + + .dark .field-error { + background: rgba(239, 68, 68, 0.08); + } + + .dark .ds-type-select-trigger.field-error { + background: rgba(239, 68, 68, 0.08); + } + + .dark .pg-cert-pill { + border-color: rgba(74, 222, 128, 0.3); + background: rgba(74, 222, 128, 0.08); + color: #4ade80; + } + + .dark .pg-cert-link { + color: #4ade80; + } + + .dark .pg-cert-link:hover, + .dark .pg-cert-link:focus-visible { + color: #86efac; + } + + .dark .test-connection-status.connected .test-connection-detail { + color: #4ade80; + } + + .dark .test-connection-status.failed .test-connection-detail { + color: #f87171; + } + + .dark .d1-oauth-warning { + color: #f87171; + } diff --git a/frontend/src/styles/ui/history.css b/frontend/src/styles/ui/history.css new file mode 100644 index 0000000..5074abf --- /dev/null +++ b/frontend/src/styles/ui/history.css @@ -0,0 +1,430 @@ + .history-grid { + display: grid; + gap: 12px; + max-width: 1200px; + } + + .history-grid--agent { + max-width: none; + } + + .history-subtabs { + display: inline-flex; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 10px); + overflow: hidden; + margin-bottom: 14px; + background: var(--panel-strong, var(--panel)); + width: fit-content; + } + + .history-subtab { + flex: 0 0 auto; + white-space: nowrap; + border: 0; + border-right: 1px solid var(--edge); + background: transparent; + color: var(--soft-ink); + padding: 8px 18px; + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + } + + .history-subtab:last-child { + border-right: 0; + } + + .history-subtab:hover:not(.history-subtab--active) { + background: color-mix(in oklab, var(--primary) 5%, transparent); + color: var(--ink); + } + + .history-subtab--active { + background: color-mix(in oklab, var(--primary) 12%, transparent); + color: var(--primary); + font-weight: 700; + } + + .history-card { + display: grid; + gap: 10px; + } + + .history-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + .history-statement { + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + background: transparent; + border: none; + padding: 0; + text-align: left; + cursor: pointer; + line-height: 1.4; + display: block; + } + + .history-statement--static { + cursor: default; + } + + .history-card-actions { + display: flex; + align-items: center; + gap: 8px; + } + + .history-timestamp { + font-size: 11px; + color: var(--soft-ink); + white-space: nowrap; + } + + .history-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + font-size: 12px; + color: var(--soft-ink); + } + + .history-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + + .history-filter-pills { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + + .history-pill { + cursor: default; + } + + .history-agent-entry__identity { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; + color: var(--ink); + } + + .history-agent-entry__identity-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--soft-ink); + } + + .history-agent-entry__identity-name { + font-weight: 600; + color: var(--ink); + word-break: break-all; + } + + .history-agent-revoked { + border-color: color-mix(in oklab, var(--danger) 40%, var(--edge)); + background: color-mix(in oklab, var(--danger) 12%, var(--panel-strong)); + color: color-mix(in oklab, var(--danger) 92%, var(--ink)); + font-weight: 600; + } + + .history-agent-filter { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--soft-ink); + } + + .history-agent-filter__label { + font-weight: 600; + } + + .history-agent-filter__select { + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--edge); + border-radius: var(--control-radius, 8px); + background: var(--panel-strong, var(--panel)); + color: var(--ink); + font: inherit; + font-size: 12px; + } + + .history-agent-entry__title { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; + } + + .history-agent-entry__tool { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 13px; + font-weight: 700; + color: var(--ink); + background: color-mix(in oklab, var(--primary) 8%, var(--panel-strong, var(--panel))); + border: 1px solid color-mix(in oklab, var(--primary) 22%, var(--edge)); + border-radius: 6px; + padding: 2px 8px; + width: fit-content; + } + + .history-agent-entry__summary { + font-size: 12px; + color: var(--soft-ink); + line-height: 1.5; + } + + .history-agent-protocol { + border-color: color-mix(in oklab, var(--chart-1) 42%, var(--edge)); + background: color-mix(in oklab, var(--chart-1) 12%, var(--panel-strong)); + color: color-mix(in oklab, var(--chart-1) 92%, var(--ink)); + } + + .history-agent-status { + border-color: transparent; + } + + .history-agent-status--success { + background: var(--success-bg); + color: color-mix(in oklab, var(--success) 92%, var(--ink)); + } + + .history-agent-status--error, + .history-agent-status--approval_required { + background: color-mix(in oklab, var(--danger) 12%, var(--panel-strong)); + color: color-mix(in oklab, var(--danger) 92%, var(--ink)); + } + + .history-agent-entry__statement-wrap { + display: grid; + gap: 6px; + } + + .history-agent-entry__statement-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--soft-ink); + } + + .history-agent-entry__statement { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--edge); + border-radius: 12px; + background: color-mix(in oklab, var(--panel-strong) 82%, black 4%); + } + + .history-agent-entry__rejection { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--danger) 32%, var(--edge)); + background: color-mix(in oklab, var(--danger) 8%, var(--panel-strong)); + } + + .history-agent-entry__rejection--approval_required { + border-color: color-mix(in oklab, var(--warning, var(--danger)) 32%, var(--edge)); + background: color-mix(in oklab, var(--warning, var(--danger)) 8%, var(--panel-strong)); + } + + .history-agent-entry__rejection-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in oklab, var(--danger) 80%, var(--ink)); + } + + .history-agent-entry__rejection--approval_required .history-agent-entry__rejection-label { + color: color-mix(in oklab, var(--warning, var(--danger)) 80%, var(--ink)); + } + + .history-agent-entry__rejection-message { + font-size: 13px; + color: var(--ink); + line-height: 1.5; + word-break: break-word; + } + + .history-agent-entry__risk { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in oklab, var(--warning, var(--danger)) 32%, var(--edge)); + background: color-mix(in oklab, var(--warning, var(--danger)) 6%, var(--panel-strong)); + } + + .history-agent-entry__risk--block, + .history-agent-entry__risk--error { + border-color: color-mix(in oklab, var(--danger) 32%, var(--edge)); + background: color-mix(in oklab, var(--danger) 8%, var(--panel-strong)); + } + + .history-agent-entry__risk--allow { + border-color: color-mix(in oklab, var(--success, var(--ok)) 32%, var(--edge)); + background: color-mix(in oklab, var(--success, var(--ok)) 6%, var(--panel-strong)); + } + + .history-agent-entry__risk-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .history-agent-entry__risk-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--soft-ink); + } + + .history-agent-entry__risk-action--block { + border-color: color-mix(in oklab, var(--danger) 50%, var(--edge)); + background: color-mix(in oklab, var(--danger) 16%, var(--panel-strong)); + color: color-mix(in oklab, var(--danger) 80%, var(--ink)); + } + + .history-agent-entry__risk-action--require_approval { + border-color: color-mix(in oklab, var(--warning, var(--danger)) 50%, var(--edge)); + background: color-mix(in oklab, var(--warning, var(--danger)) 16%, var(--panel-strong)); + color: color-mix(in oklab, var(--warning, var(--danger)) 80%, var(--ink)); + } + + .history-agent-entry__risk-level--high { color: color-mix(in oklab, var(--danger) 80%, var(--ink)); } + .history-agent-entry__risk-level--medium { color: color-mix(in oklab, var(--warning, var(--danger)) 80%, var(--ink)); } + .history-agent-entry__risk-level--low { color: var(--soft-ink); } + + .history-agent-entry__risk-rule, + .history-agent-entry__risk-reasons { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + font-size: 13px; + color: var(--ink); + line-height: 1.5; + } + + .history-agent-entry__risk-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--soft-ink); + } + + .history-agent-entry__risk-rule-link { + color: var(--accent); + text-decoration: underline; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + font-size: inherit; + font-family: inherit; + font-weight: inherit; + } + + .history-agent-entry__risk-rule-link:hover { + color: color-mix(in oklab, var(--accent) 80%, var(--ink)); + } + + .history-agent-entry__risk-reasons-list { + margin: 0; + padding-left: 18px; + list-style: disc; + } + + .history-tag--datasource, + .history-tag--type, + .history-pill[class*="datasource-type--"] { + border-color: color-mix(in oklab, var(--tag-hue) 40%, var(--edge)); + background: color-mix(in oklab, var(--tag-hue) 12%, var(--panel-strong)); + color: color-mix(in oklab, var(--tag-hue) 90%, var(--ink)); + } + + .history-tag--db { + border-color: color-mix(in oklab, var(--success) 40%, var(--edge)); + background: var(--success-bg); + color: color-mix(in oklab, var(--success) 92%, var(--ink)); + } + + .history-tag--target { + border-color: color-mix(in oklab, var(--chart-2) 40%, var(--edge)); + background: color-mix(in oklab, var(--chart-2) 12%, var(--panel-strong)); + color: color-mix(in oklab, var(--chart-2) 90%, var(--ink)); + } + + .datasource-type { + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 600; + color: color-mix(in oklab, var(--tag-hue) 88%, var(--ink)); + } + + .datasource-type::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--tag-hue); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--tag-hue) 30%, var(--edge)); + } + + .datasource-type--mysql { + --tag-hue: var(--ds-mysql); + } + + .datasource-type--postgresql { + --tag-hue: var(--ds-postgresql); + } + + .datasource-type--mongodb { + --tag-hue: var(--ds-mongodb); + } + + .datasource-type--redis { + --tag-hue: var(--ds-redis); + } + + .datasource-type--elasticsearch { + --tag-hue: var(--ds-elasticsearch); + } + + .datasource-type--redis_cluster { + --tag-hue: var(--ds-redis-cluster); + } + + .datasource-type--unknown { + --tag-hue: var(--ds-unknown); + } diff --git a/frontend/src/styles/visualization.css b/frontend/src/styles/visualization.css new file mode 100644 index 0000000..1a30bc2 --- /dev/null +++ b/frontend/src/styles/visualization.css @@ -0,0 +1,163 @@ + .visualization-empty { + max-width: 920px; + } + + .visualization-shell { + margin-top: 14px; + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + gap: 14px; + align-items: start; + } + + @media (max-width: 960px) { + .visualization-shell { + grid-template-columns: 1fr; + } + } + + .visualization-history { + display: grid; + gap: 10px; + min-height: 0; + max-height: clamp(360px, calc(100vh - 240px), 860px); + overflow: hidden; + } + + .visualization-history-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .visualization-history-head h3 { + margin: 0; + } + + .visualization-history-list { + display: grid; + gap: 8px; + overflow: auto; + overscroll-behavior: contain; + padding-right: 4px; + } + + .visualization-history-item { + text-align: left; + border: 1px solid var(--edge); + background: var(--panel-soft); + border-radius: 12px; + padding: 10px 12px; + cursor: pointer; + display: grid; + gap: 4px; + color: var(--ink); + } + + .visualization-history-item:hover { + background: var(--panel); + } + + .visualization-history-item.active { + background: color-mix(in oklab, var(--primary) 12%, var(--panel)); + border-color: color-mix(in oklab, var(--primary) 35%, var(--edge)); + } + + .visualization-history-title { + font-size: 12px; + font-weight: 700; + } + + .visualization-history-meta { + font-size: 11px; + } + + .visualization-main { + min-width: 0; + display: grid; + gap: 12px; + } + + .visualization-query { + border: 1px solid var(--edge); + background: var(--panel-soft); + border-radius: 12px; + padding: 10px 12px; + } + + .visualization-query > summary { + cursor: pointer; + font-weight: 700; + color: var(--ink); + list-style: none; + } + + .visualization-query > summary::-webkit-details-marker { + display: none; + } + + .visualization-query-code { + margin: 8px 0 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--edge); + background: var(--panel-strong); + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + max-height: min(40vh, 320px); + overflow: auto; + } + + .visualization-settings-grid { + margin-top: 10px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + } + + .visualization-setting { + display: grid; + gap: 4px; + padding: 8px 10px; + border: 1px solid var(--edge); + border-radius: 12px; + background: var(--panel); + } + + .visualization-card { + display: flex; + flex-direction: column; + gap: 12px; + } + + .visualization-card-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 14px; + } + + .visualization-title { + margin: 0; + } + + .visualization-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } + + .visualization-stage { + width: 100%; + height: clamp(420px, calc(100vh - 240px), 860px); + min-height: 420px; + border-radius: 12px; + overflow: hidden; + background: var(--panel-strong); + border: 1px solid var(--edge); + } diff --git a/frontend/src/test/wailsjs/go/main/App.ts b/frontend/src/test/wailsjs/go/main/App.ts new file mode 100644 index 0000000..71b4eb6 --- /dev/null +++ b/frontend/src/test/wailsjs/go/main/App.ts @@ -0,0 +1,345 @@ +const asyncNoop = async (..._args: any[]) => undefined +const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) + +let mockRiskRules = [ + { + id: 'probe-no-index', + code: 'PRB-003', + builtin: true, + enabled: true, + description: 'Warn when the execution plan does not show index usage', + action: 'warn', + reason: 'no index detected', + priority: 50, + scope: { dsTypes: ['mysql', 'postgresql', 'd1', 'mongodb'] }, + thresholds: { + seqScanRowsThreshold: 10000, + costThreshold: 1000, + allowSafeSeqScan: true, + }, + }, + { + id: 'probe-wide-scan', + code: 'PRB-004', + builtin: true, + enabled: true, + description: 'Warn when the execution plan examines too many rows', + action: 'warn', + reason: 'examined rows over threshold', + priority: 50, + scope: { dsTypes: ['mysql', 'postgresql', 'd1', 'mongodb'] }, + thresholds: { + maxExaminedRows: 1000, + }, + }, + { + id: 'probe-plan-risk', + code: 'PRB-005', + builtin: true, + enabled: true, + description: 'Warn when the execution plan shows scan-heavy or complex access paths', + action: 'warn', + reason: 'execution plan shows high-cost access patterns', + priority: 50, + scope: { dsTypes: ['mysql', 'postgresql', 'd1', 'mongodb', 'elasticsearch'] }, + thresholds: { + maxJoinCount: 4, + maxFullScans: 1, + maxEstimatedJoinRows: 10000, + }, + }, + { + id: 'probe-access-path', + code: 'PRB-007', + builtin: true, + enabled: true, + description: 'Warn when a DynamoDB access path cannot be verified', + action: 'warn', + reason: 'access path not verified', + priority: 50, + scope: { dsTypes: ['dynamodb'] }, + thresholds: { + maxDynamoDBPages: 20, + maxDynamoDBEvaluatedItems: 5000, + }, + }, + { + id: 'sql-allow-insert', + code: 'SQL-002', + builtin: true, + enabled: false, + description: 'Allow ordinary INSERT statements when explicitly enabled', + action: 'allow', + reason: 'ordinary INSERT allowed', + priority: 60, + scope: { dsTypes: ['mysql', 'postgresql', 'd1'] }, + }, + { + id: 'sql-warn-insert', + code: 'SQL-009', + builtin: true, + enabled: true, + description: 'Warn on INSERT/REPLACE — write operation', + action: 'warn', + reason: 'INSERT/REPLACE', + priority: 40, + scope: { dsTypes: ['mysql', 'postgresql', 'd1'] }, + }, + { + id: 'custom-user-rule', + code: 'CR-001', + builtin: false, + enabled: true, + description: 'Warn on UPDATE for selected MySQL tables', + action: 'warn', + reason: 'custom write review', + priority: 80, + scope: { dsTypes: ['mysql'] }, + }, +] + +export const AssistMongo = asyncNoop +export const StartupRecoveryStatus = async () => ({ state: 'ready' }) +export const StartupRecoveryRetry = async () => ({ state: 'ready' }) +export const StartupRecoveryOpenLogs = asyncNoop +export const StartupRecoveryOpenUpdatePage = asyncNoop +export const StartupRecoveryMoveAsideAndRestart = async (_confirmed: boolean) => ({ state: 'ready' }) +export const CreateAIConfig = asyncNoop +export const DeleteAIConfig = asyncNoop +export const GetAIConfigAPIKey = asyncNoop +export const GetAppVersion = async () => 'dev' +export const ListAIConfigs = asyncNoop +export const ListAIProviders = asyncNoop +export const TestAIConfig = asyncNoop +export const TestAIConfigPayload = asyncNoop +export const TestAIConfigPreview = asyncNoop +export const UpdateAIConfig = asyncNoop + +export const ListEmbeddingConfigs = async (..._args: any[]) => [] +export const CreateEmbeddingConfig = asyncNoop +export const UpdateEmbeddingConfig = asyncNoop +export const DeleteEmbeddingConfig = asyncNoop +export const ListEmbeddingProviders = async (..._args: any[]) => ({}) +export const TestEmbeddingConfig = asyncNoop +export const TestEmbeddingConfigPayload = asyncNoop +export const ComputeEmbeddingForSearch = async (..._args: any[]) => [] + +export const AiChatApprove = asyncNoop +export const AiChatCancelStream = asyncNoop +export const AiChatTurn = asyncNoop +export const AiChatTurnStream = asyncNoop + +export const CompleteAuthLogin = asyncNoop +export const CurrentAuth = asyncNoop +export const EnsureAuthenticated = asyncNoop +export const ListAuthDevices = asyncNoop +export const LogoutAuth = asyncNoop +export const PollAuthLogin = asyncNoop +export const RemoveAuthDevice = asyncNoop +export const StartAuthLogin = asyncNoop + +export const CreateDatasource = asyncNoop +export const DeleteDatasource = asyncNoop +export const GetDatasource = asyncNoop +export const ListDatasources = asyncNoop +export const ListSecretProviders = async (..._args: any[]) => [] +export const ListEntities = async (..._args: any[]) => [] +export const RiskEngineAddRule = async (rule: any) => { + mockRiskRules = [...mockRiskRules, cloneJson(rule)] +} +export const RiskEngineDeleteRule = async (id: string) => { + mockRiskRules = mockRiskRules.filter((rule) => rule.id !== id) +} +export const RiskEngineListRules = async (..._args: any[]) => cloneJson(mockRiskRules) +export const RiskEngineListUserRules = async (..._args: any[]) => + cloneJson(mockRiskRules.filter((rule) => !rule.builtin)) +export const RiskEngineSetEnabled = async (id: string, enabled: boolean) => { + mockRiskRules = mockRiskRules.map((rule) => ( + rule.id === id ? { ...rule, enabled } : rule + )) +} +export const RiskEngineSetBuiltinEnabled = async (id: string, enabled: boolean) => { + mockRiskRules = mockRiskRules.map((rule) => ( + rule.builtin && rule.id === id ? { ...rule, enabled } : rule + )) +} +export const RiskEngineUpdateBuiltinProbeRuleThresholds = async (id: string, thresholds: any) => { + mockRiskRules = mockRiskRules.map((rule) => ( + rule.builtin && rule.id === id + ? { ...rule, thresholds: cloneJson({ ...(rule.thresholds || {}), ...(thresholds || {}) }) } + : rule + )) +} +export const RiskEngineUpdateRule = async (id: string, nextRule: any) => { + mockRiskRules = mockRiskRules.map((rule) => ( + rule.id === id ? cloneJson({ ...nextRule, id }) : rule + )) +} +export const TestDatasource = asyncNoop +export const UpdateDatasource = asyncNoop +export const SetDatasourceTrustLevel = asyncNoop + +let mockSchemaConsents: Record = {} +let mockSchemaAudit: Array> = [] +export const SchemaPrivacyListConsents = async () => ({ + items: Object.entries(mockSchemaConsents).map(([id, value]) => ({ + datasourceId: id, + datasourceName: id, + datasourceType: 'mysql', + consent: value.consent || '', + lastSentAt: value.lastSentAt || '', + lastStatus: value.lastStatus || '', + })), +}) +export const SchemaPrivacyGetConsent = async (id: string) => ({ + datasourceId: id, + datasourceName: id, + datasourceType: 'mysql', + consent: mockSchemaConsents[id]?.consent || '', +}) +export const SchemaPrivacySetConsent = async (id: string, consent: string) => { + mockSchemaConsents[id] = { ...(mockSchemaConsents[id] || { consent: '' }), consent } + return { datasourceId: id, consent } +} +export const SchemaPrivacyListAudit = async (id: string, _limit: number) => ({ + items: id ? mockSchemaAudit.filter((entry) => entry.datasourceId === id) : mockSchemaAudit, +}) + +export const AppendHistory = asyncNoop +export const ClearHistory = asyncNoop +export const DeleteHistory = asyncNoop +export const GetHistory = asyncNoop +export const ListHistory = asyncNoop +export const ListAgentAudit = async (..._args: any[]) => [] + +export const DetectAIAgents = asyncNoop +export const InstallSkill = asyncNoop +export const UninstallSkill = asyncNoop +export const MarkSkillInstallPrompted = asyncNoop +export const SkillInstallPrompted = asyncNoop +export const DetectMCPAgents = asyncNoop +export const InstallMCP = asyncNoop +export const UninstallMCP = asyncNoop +export const AuthorizeCodexPlugin = asyncNoop +export const GetManualInstallInfo = async (..._args: any[]) => ({ + cliBinaryPath: '', + accessKey: '', + agentName: '', + skillTemplates: [], + mcpSnippets: [], +}) +export const GetManualInstallInfoForKey = async (accessKey: string) => ({ + cliBinaryPath: '', + accessKey, + agentName: '', + skillTemplates: [], + mcpSnippets: [], +}) +export const CreateManualAgent = async (name: string) => ({ + accessKey: '', + name, + agentType: 'manual', + source: 'manual', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +}) +export const RenameAgentIdentity = async (accessKey: string, name: string) => ({ accessKey, name }) +export const RevokeAgentIdentity = async (accessKey: string) => ({ accessKey, revokedAt: new Date().toISOString() }) +export const UnrevokeAgentIdentity = async (accessKey: string) => ({ accessKey, revokedAt: '' }) +export const SetAgentSensitivityGrant = async (accessKey: string, grant: boolean) => ({ + accessKey, + name: '', + agentType: 'manual', + source: 'manual', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + sensitivityClassificationGrant: grant, +}) +export const SetAgentDatasourceManagementGrant = async (accessKey: string, grant: boolean) => ({ + accessKey, + name: '', + agentType: 'manual', + source: 'manual', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + datasourceManagementGrant: grant, +}) +export const ListAgentIdentities = async () => [] as Array<{ + accessKey: string + name: string + agentType: string + source: string + installPath?: string + revokedAt?: string + createdAt: string + updatedAt: string + sensitivityClassificationGrant?: boolean + datasourceManagementGrant?: boolean +}> + +let mockRedisProtobufSchemas: Array<{ + id: string + datasourceId: string + name: string + content: string + createdAt: string + updatedAt: string +}> = [] +let mockRedisProtobufCounter = 0 + +export const ListRedisProtobufSchemas = async (datasourceId: string) => { + const id = String(datasourceId || '').trim() + if (!id) return cloneJson(mockRedisProtobufSchemas) + return cloneJson( + mockRedisProtobufSchemas.filter((s) => s.datasourceId === id || s.datasourceId === ''), + ) +} +export const GetRedisProtobufSchema = async (id: string) => { + const found = mockRedisProtobufSchemas.find((s) => s.id === id) + if (!found) throw new Error('redis protobuf schema not found') + return cloneJson(found) +} +export const SaveRedisProtobufSchema = async (payload: { + id?: string + datasourceId?: string + name: string + content: string +}) => { + const now = new Date().toISOString() + const name = String(payload.name || '').trim() + const content = String(payload.content || '') + if (!name) throw new Error('name is required') + if (!content.trim()) throw new Error('content is required') + if (payload.id) { + const idx = mockRedisProtobufSchemas.findIndex((s) => s.id === payload.id) + if (idx < 0) throw new Error('redis protobuf schema not found') + mockRedisProtobufSchemas[idx] = { + ...mockRedisProtobufSchemas[idx], + name, + content, + datasourceId: String(payload.datasourceId || ''), + updatedAt: now, + } + return cloneJson(mockRedisProtobufSchemas[idx]) + } + mockRedisProtobufCounter += 1 + const created = { + id: `rps_mock_${mockRedisProtobufCounter}`, + datasourceId: String(payload.datasourceId || ''), + name, + content, + createdAt: now, + updatedAt: now, + } + mockRedisProtobufSchemas.push(created) + return cloneJson(created) +} +export const DeleteRedisProtobufSchema = async (id: string) => { + const before = mockRedisProtobufSchemas.length + mockRedisProtobufSchemas = mockRedisProtobufSchemas.filter((s) => s.id !== id) + return mockRedisProtobufSchemas.length !== before +} +export const __resetRedisProtobufMocks = () => { + mockRedisProtobufSchemas = [] + mockRedisProtobufCounter = 0 +} diff --git a/frontend/src/test/wailsjs/go/models.ts b/frontend/src/test/wailsjs/go/models.ts new file mode 100644 index 0000000..64b1670 --- /dev/null +++ b/frontend/src/test/wailsjs/go/models.ts @@ -0,0 +1,36 @@ +class MockRiskRule { + id = '' + builtin = false + enabled = true + + constructor(input: Record = {}) { + Object.assign(this, input) + } +} + +class MockRiskRuleCondition { + constructor(input: Record = {}) { + Object.assign(this, input) + } +} + +class MockRiskRuleThresholds { + constructor(input: Record = {}) { + Object.assign(this, input) + } +} + +class MockRiskRuleScope { + constructor(input: Record = {}) { + Object.assign(this, input) + } +} + +export const aichat = {} + +export const riskengine = { + Rule: MockRiskRule, + RuleCondition: MockRiskRuleCondition, + RuleThresholds: MockRiskRuleThresholds, + RuleScope: MockRiskRuleScope, +} diff --git a/frontend/src/test/wailsjs/runtime/runtime.ts b/frontend/src/test/wailsjs/runtime/runtime.ts new file mode 100644 index 0000000..787df59 --- /dev/null +++ b/frontend/src/test/wailsjs/runtime/runtime.ts @@ -0,0 +1,12 @@ +export const EventsOn = () => () => {} +export const BrowserOpenURL = async (_url: string) => undefined +export const WindowSetDarkTheme = async () => undefined +export const WindowSetLightTheme = async () => undefined +export const WindowMinimise = async () => undefined +export const WindowToggleMaximise = async () => undefined +export const Quit = async () => undefined +export const Environment = async () => ({ + platform: 'darwin', + arch: 'arm64', + buildType: 'development', +}) diff --git a/frontend/src/types/ai-chat.ts b/frontend/src/types/ai-chat.ts new file mode 100644 index 0000000..5d81d7b --- /dev/null +++ b/frontend/src/types/ai-chat.ts @@ -0,0 +1,71 @@ +export type AiRole = 'user' | 'assistant' + +export interface AiContextChip { + id: string + label: string + kind: 'datasource' | 'database' | 'collection' | 'table' + datasourceId?: string +} + +export interface AiMessage { + id: string + role: AiRole + content: string + createdAt: number + context: AiContextChip[] + implicitStatement?: string + agent?: AiAgentDecision + plan?: AiAgentPlan +} + +export interface AiConversation { + id: string + title: string + createdAt: number + updatedAt: number +} + +export interface AiApproval { + id: string + kind: string + summary: string + payload: any +} + +export interface AiConsoleResultEffect { + datasourceId: string + datasourceType?: string + database?: string + statement?: string + result: import('@/types').QueryResult +} + +export interface AiChatInFlightTurn { + turnId: string + conversationId: string + assistantMessageId: string + streamId?: string + progressPlaceholder?: string + createdAt: number +} + +export interface AiAgentDecision { + mode?: string + complexity?: string + reason?: string + confidence?: number +} + +export interface AiAgentPlanStep { + id?: string + title?: string + description?: string + status?: string +} + +export interface AiAgentPlan { + title?: string + summary?: string + markdown?: string + steps?: AiAgentPlanStep[] +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..06a0e85 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,401 @@ +export type DataSourceType = 'mysql' | 'postgresql' | 'mongodb' | 'redis' | 'elasticsearch' | 'dynamodb' | 'd1' | 'chromadb' + +export interface SecretRef { + providerConfigId: string + scope?: string + resourceId?: string + field: string + key: string + version?: string + fingerprint?: string +} + +export interface SecretProviderSummary { + id: string + type: string + name: string + default: boolean + address?: string + mount?: string +} + +export interface DataSource { + id: string + name: string + type: DataSourceType + host: string + port: number + username?: string + password?: string + database?: string + authSource?: string + options?: Record + secretRefs?: Record +} + +export interface DatasourceMetrics { + datasourceId: string + datasourceType: string + collectedAt: number + node?: string + nodes?: string[] + cpuAvailable: boolean + cpuPercent?: number + cpuUserSeconds?: number + cpuSystemSeconds?: number + memoryAvailable: boolean + memoryUsedBytes?: number + memoryTotalBytes?: number + memoryUsedText?: string + memoryTotalText?: string + warnings?: string[] + raw?: Record +} + +export interface ColumnInfo { + name: string + dataType: string + nullable: string + defaultValue?: any +} + +export interface IndexInfo { + name: string + column?: string + unique: boolean + definition?: string +} + +export interface DetailItem { + label: string + value: any +} + +export interface DescribeResult { + columns: ColumnInfo[] + indexes: IndexInfo[] + details?: DetailItem[] + preview?: any +} + +export interface ResultColumnOrigin { + schema?: string + table?: string + column?: string +} + +export interface ResultColumn { + key: string + name: string + position: number + sourceKind?: string + origins?: ResultColumnOrigin[] + conservativeMask?: boolean + masked?: boolean +} + +export interface ExecuteRiskInfo { + action: string + level: string + reasons?: string[] + ruleId?: string + ruleCode?: string + ruleDescription?: string + targetEntity?: string + explain?: ExplainResult +} + +export interface DynamoStatementRepairDetail { + kind: string + originalStatement: string + repairedStatement: string + reason: string +} + +export interface DynamoIndexSuggestionDetail { + kind: string + table: string + index: string + partitionKey: string + suggestedStatement: string + reason: string +} + +export interface DynamoExecutionDetail { + kind?: string + pageSize?: number + requestedPageSize?: number + effectivePageSize?: number + maxReturnedRows?: number + maxPages?: number + maxEvaluatedItems?: number + requestedLimits?: { + pageSize?: number + maxReturnedRows?: number + maxPages?: number + maxEvaluatedItems?: number + } + effectiveLimits?: { + pageSize?: number + maxReturnedRows?: number + maxPages?: number + maxEvaluatedItems?: number + } + pagesFetched?: number + rowsReturned?: number + hasMore?: boolean + nextToken?: string + nextTokenState?: string + stopReason?: string + clampedLimits?: Record + statementRepair?: DynamoStatementRepairDetail + indexSuggestion?: DynamoIndexSuggestionDetail +} + +export interface QueryResult { + columns: string[] + rows: Array> + columnMeta?: ResultColumn[] + rowValues?: any[][] + rowCount: number + elapsedMs: number + hasMore?: boolean + nextToken?: string + prevToken?: string + detail?: DynamoExecutionDetail | any + riskInfo?: ExecuteRiskInfo +} + +export interface ExplainResult { + usesIndex: boolean + indexes?: string[] + stages?: string[] + totalKeysExamined?: number + totalDocsExamined?: number + detail: any +} + +export interface EntityPage { + items: string[] + cursor: string + done: boolean + details?: Record + kinds?: Record +} + +export interface RedisKeyPage { + keys: string[] + cursor: string + done: boolean +} + +export interface RedisCommandDocsResponse { + updatedAt: number + commands: Record +} + +export type ProviderType = + | 'openai' + | 'anthropic' + | 'gemini' + | 'qwen' + | 'zhipu' + | 'deepseek' + | 'openrouter' + | 'ollama' + | 'lmstudio' + | 'custom' + +export interface AIConfig { + id: string + name: string + provider: ProviderType + baseUrl: string + apiKey: string + model: string + purpose?: 'chat' | 'embedding' + status: string + statusDetail?: string + lastCheckedAt?: number + lastLatencyMs?: number + lastModelInfo?: string + createdAt?: number + options?: Record +} + +export interface ProviderInfo { + name: string + baseUrl: string + defaultModel: string + models: string[] +} + +export interface TestResult { + connected: boolean + latencyMs: number + modelInfo?: string + error?: string +} + +export interface MongoAIRequest { + datasourceId: string + action: string + statement: string + error?: string + prompt?: string + collection?: string + database?: string + fields?: string[] + indexes?: string[] +} + +export interface MongoAIResponse { + statement: string + explanation?: string + warnings?: string[] +} + +export interface HistoryItem { + statement: string + at: string +} + +export interface HistoryEntry { + id: string + statement: string + executedAt: string + datasourceId: string + datasourceName: string + datasourceType: DataSourceType | string + database: string + targets: string[] + tags: string[] +} + +export interface HistoryFilter { + datasourceId?: string + target?: string + database?: string + keyword?: string + limit?: number +} + +export type AgentRiskAttributionSource = 'risk_engine' | 'policy' + +export interface AgentRiskAttribution { + source: AgentRiskAttributionSource + action: string + level?: string + ruleId?: string + ruleCode?: string + ruleDescription?: string + // builtin — true when the matched rule ships with the engine, false for + // user-authored rules. Drives the `source=` query param on the "View + // rule" link so RiskRulesView can scroll to the correct row when a user + // rule and a builtin rule share the same id. + builtin?: boolean + reasons?: string[] +} + +export interface AgentAuditEntry { + id: string + accessKey: string + agentName: string + agentType?: string + protocol: string + toolName: string + summary: string + statement?: string + datasourceId?: string + datasourceName?: string + datasourceType?: string + target?: string + status: string + message?: string + riskAttribution?: AgentRiskAttribution + executedAt: string +} + +export interface AgentAuditFilter { + accessKey?: string + protocol?: string + keyword?: string + limit?: number +} + +export interface MongoBrowseState { + active: boolean + collection: string + pageSize: number + pageIndex: number + firstId: any + lastId: any + lastCount: number +} + +export interface AuthUser { + id: string + email: string + displayName: string + avatarUrl: string +} + +export interface AuthLicense { + plan: string + status: string + expiresAt: number +} + +export interface AuthSession { + accessToken: string + refreshToken: string + expiresAt: number + user: AuthUser + license: AuthLicense +} + +export interface AuthPendingLogin { + sessionId: string + codeVerifier: string + loginUrl: string +} + +export interface AuthTrial { + startedAt: number + expiresAt: number +} + +export interface AuthState { + deviceId: string + pendingLogin?: AuthPendingLogin | null + session?: AuthSession | null + trial?: AuthTrial | null +} + +export interface AuthLoginStart { + loginUrl: string + sessionId: string +} + +export interface AuthLoginPoll { + status: string + code?: string +} + +export interface AuthDeviceInfo { + deviceId: string + deviceName: string + platform: string + lastActiveAt: number + createdAt: number +} + +export interface AuthDeviceList { + devices: AuthDeviceInfo[] + limit: number + plan: string + // license is the freshly-resolved license from the backend after refreshing + // the session. Frontend stores apply this so a stale local plan/status + // (e.g. cached pro after expiry) does not contradict plan-limit responses. + license?: AuthLicense | null +} diff --git a/frontend/src/types/userkb.ts b/frontend/src/types/userkb.ts new file mode 100644 index 0000000..c6c9c8c --- /dev/null +++ b/frontend/src/types/userkb.ts @@ -0,0 +1,63 @@ +export type UserKBCategoryScope = 'all' | 'datasource' + +export type UserKBParseStatus = 'queued' | 'ok' | 'failed' + +export type UserKBSummaryStatus = + | 'queued' + | 'ok' + | 'failed' + | 'needs_provider' + | 'skipped' + +export interface UserKBCategory { + id: string + name: string + description?: string + scope: UserKBCategoryScope + datasourceIds?: string[] + createdAt: number + updatedAt: number +} + +export interface UserKBFile { + id: string + categoryId: string + originalName: string + ext: string + size: number + uploadPath: string + parsedPath: string + parseStatus: UserKBParseStatus + parseError?: string + summaryStatus: UserKBSummaryStatus + summaryError?: string + note?: string + aiSummary?: string + keywords?: string[] + createdAt: number + updatedAt: number +} + +export interface UserKBStoreState { + version: number + categories: UserKBCategory[] + files: UserKBFile[] +} + +export interface UserKBViewState { + state: UserKBStoreState + aiProviderReady: boolean + aiProviderMessage?: string +} + +export interface UserKBCategoryInput { + name: string + description?: string + scope: UserKBCategoryScope + datasourceIds?: string[] +} + +export interface UserKBUploadFileInput { + name: string + base64: string +} diff --git a/frontend/src/utils/scrolling.ts b/frontend/src/utils/scrolling.ts new file mode 100644 index 0000000..418695f --- /dev/null +++ b/frontend/src/utils/scrolling.ts @@ -0,0 +1,17 @@ +export type RowMetrics = { + index: number + offsetTop: number + offsetHeight: number +} + +export const firstVisibleRowIndex = (rows: RowMetrics[], scrollTop: number, headerHeight = 0) => { + if (!rows.length) return 0 + const offset = Math.max(0, headerHeight) + const threshold = scrollTop + offset + 1 + for (const row of rows) { + if (row.offsetTop + row.offsetHeight >= threshold) { + return row.index + } + } + return rows[rows.length - 1].index +} diff --git a/frontend/src/utils/vegaLite.ts b/frontend/src/utils/vegaLite.ts new file mode 100644 index 0000000..f7bee6c --- /dev/null +++ b/frontend/src/utils/vegaLite.ts @@ -0,0 +1,70 @@ +export type VegaLiteSpec = Record + +const DEFAULT_SCHEMA = 'https://vega.github.io/schema/vega-lite/v5.json' + +const defaultConfig = () => ({ + background: 'transparent', + font: + 'Nunito, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + padding: { top: 24, bottom: 18, left: 0, right: 0 }, + title: { fontSize: 14, color: '#0f172a' }, + axis: { + labelFontSize: 11, + labelColor: '#334155', + titleFontSize: 12, + titleColor: '#0f172a', + gridColor: 'rgba(15, 23, 42, 0.10)', + }, + legend: { + labelFontSize: 11, + labelColor: '#334155', + titleFontSize: 12, + titleColor: '#0f172a', + }, + range: { + category: [ + '#2563eb', + '#0ea5e9', + '#10b981', + '#f59e0b', + '#ef4444', + '#a855f7', + '#14b8a6', + '#f97316', + '#64748b', + '#22c55e', + ], + }, +}) + +const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +export const enhanceVegaLiteSpec = (spec: unknown): VegaLiteSpec => { + if (!spec || typeof spec !== 'object' || Array.isArray(spec)) { + return spec as VegaLiteSpec + } + + const out = cloneJson(spec as VegaLiteSpec) + + if (typeof out.$schema !== 'string' || !out.$schema.trim()) { + out.$schema = DEFAULT_SCHEMA + } + + if (out.width == null) out.width = 'container' + if (out.height == null) out.height = 'container' + + if (out.autosize == null) { + out.autosize = { type: 'fit', contains: 'padding' } + } + + if (typeof out.mark === 'string') { + out.mark = { type: out.mark } + } + + out.config = { + ...defaultConfig(), + ...(out.config && typeof out.config === 'object' ? out.config : {}), + } + + return out +} diff --git a/frontend/src/views/AISettingsFormView.vue b/frontend/src/views/AISettingsFormView.vue new file mode 100644 index 0000000..6be7d7f --- /dev/null +++ b/frontend/src/views/AISettingsFormView.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/views/AISettingsView.vue b/frontend/src/views/AISettingsView.vue new file mode 100644 index 0000000..09c766b --- /dev/null +++ b/frontend/src/views/AISettingsView.vue @@ -0,0 +1,68 @@ + + + diff --git a/frontend/src/views/ConsoleView.vue b/frontend/src/views/ConsoleView.vue new file mode 100644 index 0000000..e9af6fc --- /dev/null +++ b/frontend/src/views/ConsoleView.vue @@ -0,0 +1,110 @@ + + + diff --git a/frontend/src/views/DatasourceFormView.vue b/frontend/src/views/DatasourceFormView.vue new file mode 100644 index 0000000..a43ca43 --- /dev/null +++ b/frontend/src/views/DatasourceFormView.vue @@ -0,0 +1,770 @@ + + + diff --git a/frontend/src/views/DatasourceListView.vue b/frontend/src/views/DatasourceListView.vue new file mode 100644 index 0000000..9c019f9 --- /dev/null +++ b/frontend/src/views/DatasourceListView.vue @@ -0,0 +1,284 @@ + + + diff --git a/frontend/src/views/HistoryView.vue b/frontend/src/views/HistoryView.vue new file mode 100644 index 0000000..0218b0f --- /dev/null +++ b/frontend/src/views/HistoryView.vue @@ -0,0 +1,331 @@ + + + diff --git a/frontend/src/views/MyKnowledgeBaseView.vue b/frontend/src/views/MyKnowledgeBaseView.vue new file mode 100644 index 0000000..5e7bc13 --- /dev/null +++ b/frontend/src/views/MyKnowledgeBaseView.vue @@ -0,0 +1,758 @@ + + + diff --git a/frontend/src/views/SensitivityListView.vue b/frontend/src/views/SensitivityListView.vue new file mode 100644 index 0000000..3ef4ae9 --- /dev/null +++ b/frontend/src/views/SensitivityListView.vue @@ -0,0 +1,1475 @@ + + + + + diff --git a/frontend/src/views/SensitivityView.vue b/frontend/src/views/SensitivityView.vue new file mode 100644 index 0000000..6020524 --- /dev/null +++ b/frontend/src/views/SensitivityView.vue @@ -0,0 +1,995 @@ + + + + + + diff --git a/frontend/src/views/console/components/ConsoleStatementPanel.vue b/frontend/src/views/console/components/ConsoleStatementPanel.vue new file mode 100644 index 0000000..534a3c4 --- /dev/null +++ b/frontend/src/views/console/components/ConsoleStatementPanel.vue @@ -0,0 +1,954 @@ + + + diff --git a/frontend/src/views/console/components/ConsoleStatementTabs.vue b/frontend/src/views/console/components/ConsoleStatementTabs.vue new file mode 100644 index 0000000..a1441ee --- /dev/null +++ b/frontend/src/views/console/components/ConsoleStatementTabs.vue @@ -0,0 +1,524 @@ + + + diff --git a/frontend/src/views/console/components/ConsoleToolbar.vue b/frontend/src/views/console/components/ConsoleToolbar.vue new file mode 100644 index 0000000..6640e3a --- /dev/null +++ b/frontend/src/views/console/components/ConsoleToolbar.vue @@ -0,0 +1,185 @@ + + + diff --git a/frontend/src/views/console/components/ConsoleVisualizationBuilder.vue b/frontend/src/views/console/components/ConsoleVisualizationBuilder.vue new file mode 100644 index 0000000..eef2f3d --- /dev/null +++ b/frontend/src/views/console/components/ConsoleVisualizationBuilder.vue @@ -0,0 +1,323 @@ + + + diff --git a/frontend/src/views/console/components/DynamoLimitsControl.vue b/frontend/src/views/console/components/DynamoLimitsControl.vue new file mode 100644 index 0000000..69efc8a --- /dev/null +++ b/frontend/src/views/console/components/DynamoLimitsControl.vue @@ -0,0 +1,264 @@ + + + diff --git a/frontend/src/views/console/components/RedisConsoleShell.vue b/frontend/src/views/console/components/RedisConsoleShell.vue new file mode 100644 index 0000000..e8b3489 --- /dev/null +++ b/frontend/src/views/console/components/RedisConsoleShell.vue @@ -0,0 +1,2767 @@ + + + + + diff --git a/frontend/src/views/console/components/RedisKeyInspector.vue b/frontend/src/views/console/components/RedisKeyInspector.vue new file mode 100644 index 0000000..d579702 --- /dev/null +++ b/frontend/src/views/console/components/RedisKeyInspector.vue @@ -0,0 +1,554 @@ + + + diff --git a/frontend/src/views/console/components/RowMutationDialog.vue b/frontend/src/views/console/components/RowMutationDialog.vue new file mode 100644 index 0000000..136960d --- /dev/null +++ b/frontend/src/views/console/components/RowMutationDialog.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/frontend/src/views/console/components/__tests__/metricsNodeState.test.ts b/frontend/src/views/console/components/__tests__/metricsNodeState.test.ts new file mode 100644 index 0000000..2f86768 --- /dev/null +++ b/frontend/src/views/console/components/__tests__/metricsNodeState.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { buildUnavailableNodeMetrics, normalizeMetricsNode, shouldApplyUnavailableNodeMetrics } from '../metricsNodeState' +import type { DatasourceMetrics } from '@/types' + +describe('metricsNodeState', () => { + it('normalizes redis node by stripping node id suffix', () => { + expect(normalizeMetricsNode('192.168.1.10:6379@abcd')).toBe('192.168.1.10:6379') + expect(normalizeMetricsNode(' 192.168.1.10:6379 ')).toBe('192.168.1.10:6379') + expect(normalizeMetricsNode('')).toBe('') + }) + + it('applies unavailable metrics fallback whenever the failed request targets selected node', () => { + expect(shouldApplyUnavailableNodeMetrics('192.168.1.10:6379', '192.168.1.10:6379')).toBe(true) + expect(shouldApplyUnavailableNodeMetrics('192.168.1.10:6379', '192.168.1.10:6379@node-a')).toBe(true) + expect(shouldApplyUnavailableNodeMetrics('192.168.1.10:6379', '192.168.1.11:6379')).toBe(false) + expect(shouldApplyUnavailableNodeMetrics('', '')).toBe(true) + expect(shouldApplyUnavailableNodeMetrics('', '192.168.1.10:6379')).toBe(false) + }) + + it('builds unavailable metrics for the requested node without stale cpu/memory values', () => { + const previous: DatasourceMetrics = { + datasourceId: 'ds-1', + datasourceType: 'redis', + collectedAt: 1, + node: '192.168.1.10:6379', + nodes: ['192.168.1.10:6379', '192.168.1.11:6379@node-2'], + cpuAvailable: true, + cpuPercent: 32, + memoryAvailable: true, + memoryUsedBytes: 100, + memoryTotalBytes: 1000, + } + + const next = buildUnavailableNodeMetrics('ds-1', '192.168.1.11:6379', previous) + + expect(next.datasourceId).toBe('ds-1') + expect(next.datasourceType).toBe('redis') + expect(next.node).toBe('192.168.1.11:6379') + expect(next.nodes).toEqual(['192.168.1.10:6379', '192.168.1.11:6379']) + expect(next.cpuAvailable).toBe(false) + expect(next.memoryAvailable).toBe(false) + expect(next.cpuPercent).toBeUndefined() + expect(next.memoryUsedBytes).toBeUndefined() + expect(next.warnings?.[0]).toContain('192.168.1.11:6379') + expect(next.collectedAt).toBeGreaterThan(1) + }) +}) diff --git a/frontend/src/views/console/components/chroma-dsl/ConsoleChromaDslWorkspace.vue b/frontend/src/views/console/components/chroma-dsl/ConsoleChromaDslWorkspace.vue new file mode 100644 index 0000000..b73d7f2 --- /dev/null +++ b/frontend/src/views/console/components/chroma-dsl/ConsoleChromaDslWorkspace.vue @@ -0,0 +1,822 @@ + + +